@dev.fast/whiteboard 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,3952 @@
1
+ import { c as jsonString, n as isJsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
2
+ import { a as sharedReviewId, c as verifyShareRepository, d as StreamLimitError, f as readBoundedStream, l as exportShare, m as shareIdSchema, n as ShareClient, p as parseShareLink, r as SharePreflightError, s as readShareRepository, t as ShareAuthError, u as validateShareSources } from "./client-DFoh50W3.js";
3
+ import { d as REVIEW_CLIENT_REMOTE, i as parseReviewVerbRequest, r as parseReviewDesktopVerbResult, u as REVIEW_CLIENT_HEADER } from "./src-DnwdaQ2r.js";
4
+ import { C as TraceStorageDeniedError, G as readStoreAuth, H as DEFAULT_STORE_ORIGIN, U as clearStoreAuth, W as openUrlInBrowser, _ as isTraceStorageConfigured, b as selectTraceStorage, o as listReviewTraceSessions, q as runStoreLogin, s as loadReviewAgentTrace, y as resolveTraceStorage } from "./review-agent-traces-DNJFYzc8.js";
5
+ import { a as inspectSnapshot, c as askAgentIds, d as askQuestionSchema, f as AgentSelectionSchema, i as commandSchema, l as askChoiceKinds, p as selectionMarkdown, r as SCRATCHPAD_ID, s as applyAskChange, u as askPicksSchema } from "./local-data-CxLk75rx.js";
6
+ import { $ as unionIntervals, G as selectionKey, H as comparisonKey, I as fileLineRangeSchema, J as coverageSources, N as lensSelections, Q as subtractIntervals, T as selectionReferences, U as formatAnchor, V as anchorSelection, W as resolveDiffSelection, Y as emptyCoverage, Z as scopedCoverage, h as elements, k as UNCATEGORIZED_LENS_ID, u as anchorPins } from "./stored-document-migration-DHeMpLMb.js";
7
+ import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
8
+ import { A as devReviewHome, h as TraceConfigurationError, i as isS3MockMode } from "./s3-config-CAxhO9u_.js";
9
+ import { t as errorMessage } from "./error-message-OtiDonty.js";
10
+ import { T as traceMachineEnabled } from "./review-home-paths-6zZH-1c9.js";
11
+ import { i as readReviewPackageVersion, n as readBuildCommit } from "./package-paths-B6-zxvIO.js";
12
+ import { a as REVIEW_APP_SESSION_ID_HEADER, c as sanitizeUiTelemetryEvent, i as BUNDLE_FRAME_PATTERN, l as cleanTelemetryText, r as reviewRequestOrigin, s as isReportableCleanedMessage } from "./request-origin-D_4QIdoN.js";
13
+ import { a as scratchpadAvailable, c as readQuerySchemas, i as renderInstructions, o as inspectQuerySchema, r as instructionsQuerySchema, s as queryAnchor, t as authoringTools } from "./authoring-tools-Cr6Kpjsx.js";
14
+ import { t as fuzzyRank } from "./fuzzy-match-BoAmcyak.js";
15
+ import { createRequire } from "node:module";
16
+ import crypto$1, { createHash, randomUUID } from "node:crypto";
17
+ import { z } from "zod";
18
+ import { constants, mkdirSync, writeFileSync } from "node:fs";
19
+ import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
20
+ import path, { posix } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { execFile, spawn } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { pathToFileURL } from "node:url";
25
+ import { Readable, Writable } from "node:stream";
26
+ import { getRequestListener } from "@hono/node-server";
27
+ import { Hono } from "hono";
28
+ import { PROTOCOL_VERSION, RequestError, client, methods, ndJsonStream } from "@agentclientprotocol/sdk";
29
+ import { streamSSE } from "hono/streaming";
30
+ //#region src/server/global-verb-relay.ts
31
+ const DEFAULT_VERB_TIMEOUT_MS = 45e3;
32
+ const DEFAULT_MAX_CLIENTS = 16;
33
+ const NOT_ATTACHED = "No Whiteboard Desktop is attached.";
34
+ /**
35
+ * Sends each verb to every attached client, each with its own frame id, and
36
+ * resolves with the first success, or with the last failure once no client
37
+ * is left to answer.
38
+ */
39
+ var GlobalReviewDesktopVerbRelay = class {
40
+ clients = /* @__PURE__ */ new Map();
41
+ /** Each verb in flight, under every frame id it was sent with. */
42
+ pending = /* @__PURE__ */ new Map();
43
+ timeoutMs;
44
+ maxClients;
45
+ constructor(options = {}) {
46
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_VERB_TIMEOUT_MS;
47
+ this.maxClients = options.maxClients ?? DEFAULT_MAX_CLIENTS;
48
+ }
49
+ get attached() {
50
+ return this.clients.size > 0;
51
+ }
52
+ attach(writer) {
53
+ if (this.clients.size >= this.maxClients || this.clients.has(writer) || writer.signal.aborted) return false;
54
+ const detach = () => this.detach(writer);
55
+ this.clients.set(writer, detach);
56
+ writer.signal.addEventListener("abort", detach, { once: true });
57
+ return true;
58
+ }
59
+ dispatch(value) {
60
+ const request = parseReviewVerbRequest(value);
61
+ if (this.clients.size === 0) return Promise.resolve({
62
+ ok: false,
63
+ error: NOT_ATTACHED
64
+ });
65
+ return new Promise((resolve) => {
66
+ const verb = {
67
+ resolve,
68
+ waiting: new Map([...this.clients.keys()].map((client) => [crypto$1.randomUUID(), client])),
69
+ timer: setTimeout(() => this.settle(verb, verb.lastFailure ?? {
70
+ ok: false,
71
+ error: "Whiteboard Desktop verb timed out."
72
+ }), this.timeoutMs)
73
+ };
74
+ verb.timer.unref?.();
75
+ for (const id of verb.waiting.keys()) this.pending.set(id, verb);
76
+ for (const [id, client] of [...verb.waiting]) {
77
+ const frame = `data: ${JSON.stringify({
78
+ event: "desktop-verb",
79
+ id,
80
+ request
81
+ })}\n\n`;
82
+ try {
83
+ Promise.resolve(client.write(frame)).catch(() => {
84
+ this.detach(client);
85
+ });
86
+ } catch {
87
+ this.detach(client);
88
+ }
89
+ }
90
+ });
91
+ }
92
+ acceptResult(value) {
93
+ const result = parseReviewDesktopVerbResult(value);
94
+ const verb = this.pending.get(result.id);
95
+ if (!verb) return false;
96
+ if (result.response.ok) this.settle(verb, result.response);
97
+ else {
98
+ verb.lastFailure = result.response;
99
+ this.stopWaiting(verb, result.id);
100
+ }
101
+ return true;
102
+ }
103
+ close() {
104
+ for (const verb of new Set(this.pending.values())) this.settle(verb, {
105
+ ok: false,
106
+ error: "Whiteboard Desktop relay closed."
107
+ });
108
+ for (const client of [...this.clients.keys()]) {
109
+ this.detach(client);
110
+ try {
111
+ Promise.resolve(client.close()).catch(() => void 0);
112
+ } catch {}
113
+ }
114
+ }
115
+ detach(writer) {
116
+ const listener = this.clients.get(writer);
117
+ if (!listener) return;
118
+ writer.signal.removeEventListener("abort", listener);
119
+ this.clients.delete(writer);
120
+ for (const [id, verb] of [...this.pending]) if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
121
+ }
122
+ /** Drops one client's id; with nobody left to answer, the verb fails. */
123
+ stopWaiting(verb, id) {
124
+ verb.waiting.delete(id);
125
+ this.pending.delete(id);
126
+ if (verb.waiting.size === 0) this.settle(verb, verb.lastFailure ?? {
127
+ ok: false,
128
+ error: NOT_ATTACHED
129
+ });
130
+ }
131
+ settle(verb, response) {
132
+ for (const id of verb.waiting.keys()) this.pending.delete(id);
133
+ verb.waiting.clear();
134
+ clearTimeout(verb.timer);
135
+ verb.resolve(response);
136
+ }
137
+ };
138
+ //#endregion
139
+ //#region src/server/http-json.ts
140
+ const DEFAULT_MAX_REQUEST_BYTES = 1048576;
141
+ var HttpJsonError = class extends Error {
142
+ statusCode;
143
+ constructor(message, statusCode) {
144
+ super(message);
145
+ this.statusCode = statusCode;
146
+ this.name = "HttpJsonError";
147
+ }
148
+ };
149
+ /** An error whose status and machine code the JSON routes hand straight back. */
150
+ var ReviewServerError = class extends Error {
151
+ statusCode;
152
+ code;
153
+ name = "ReviewServerError";
154
+ constructor(message, statusCode, code) {
155
+ super(message);
156
+ this.statusCode = statusCode;
157
+ this.code = code;
158
+ }
159
+ };
160
+ //#endregion
161
+ //#region src/server/hono-http.ts
162
+ function createNodeRequestListener(app) {
163
+ return getRequestListener(app.fetch);
164
+ }
165
+ function jsonResponse(body, status, options = {}) {
166
+ const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
167
+ if (options.cacheControl) headers.set("cache-control", options.cacheControl);
168
+ const serialized = JSON.stringify(body);
169
+ return new Response(options.newline === false ? serialized : `${serialized}\n`, {
170
+ status,
171
+ headers
172
+ });
173
+ }
174
+ function applyCorsHeaders(request, response) {
175
+ const origin = request.headers.get("origin");
176
+ if (origin) {
177
+ response.headers.set("access-control-allow-origin", origin);
178
+ const vary = response.headers.get("vary");
179
+ const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
180
+ if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
181
+ }
182
+ response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`);
183
+ response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
184
+ response.headers.set("access-control-allow-private-network", "true");
185
+ return response;
186
+ }
187
+ function corsPreflightResponse(request) {
188
+ return applyCorsHeaders(request, new Response(null, { status: 204 }));
189
+ }
190
+ function isAuthorizedRequest(request, expectedToken) {
191
+ const supplied = request.headers.get("x-review-token") ?? new URL(request.url).searchParams.get("token");
192
+ if (!supplied) return false;
193
+ const expected = Buffer.from(expectedToken);
194
+ const actual = Buffer.from(supplied);
195
+ return expected.length === actual.length && crypto$1.timingSafeEqual(expected, actual);
196
+ }
197
+ async function readBoundedRequestJson(request, maxBytes = DEFAULT_MAX_REQUEST_BYTES, emptyValue, options = {}) {
198
+ assertJsonContentType(request, options);
199
+ const contentLength = Number(request.headers.get("content-length"));
200
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) throw requestTooLarge(maxBytes);
201
+ let body;
202
+ try {
203
+ body = request.body ? (await readBoundedStream(request.body, maxBytes)).toString("utf8") : "";
204
+ } catch (error) {
205
+ if (error instanceof StreamLimitError) throw requestTooLarge(maxBytes);
206
+ throw error;
207
+ }
208
+ if (!body && emptyValue !== void 0) return emptyValue;
209
+ try {
210
+ return parseJsonText(body);
211
+ } catch {
212
+ throw new HttpJsonError("Invalid JSON body.", 400);
213
+ }
214
+ }
215
+ function assertJsonContentType(request, options) {
216
+ const mediaType = request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
217
+ if (mediaType !== "application/json" && !mediaType?.endsWith("+json") && !(options.allowTextPlain && mediaType === "text/plain")) throw new HttpJsonError("Content-Type must be application/json.", 415);
218
+ }
219
+ function requestTooLarge(maxBytes) {
220
+ return new HttpJsonError(`Request body exceeds ${maxBytes === 1048576 ? "1 MiB" : `${maxBytes} bytes`}.`, 413);
221
+ }
222
+ //#endregion
223
+ //#region src/error-telemetry.ts
224
+ /**
225
+ * Errors whose message quotes the document being checked. A review tool runs
226
+ * authored prose through schemas, so these carry review content by design —
227
+ * unlike an editor, which mostly reports on files. Keep the class, the digest
228
+ * and the frames; drop the message. VS Code drops file-operation errors and
229
+ * errors carrying a system code for the same reason.
230
+ */
231
+ const SCHEMA_ERROR_NAMES = /* @__PURE__ */ new Set(["ZodError", "ValidationError"]);
232
+ const MESSAGE_HASH_LENGTH = 16;
233
+ const MAX_FRAMES = 10;
234
+ const MAX_STACK_LENGTH = 16384;
235
+ const MAX_ERROR_NAME_LENGTH = 40;
236
+ const ERROR_NAME_PATTERN = /^[A-Za-z0-9_$-]+$/;
237
+ /**
238
+ * Path segments that exist only inside the shipped bundle. A frame keeps the
239
+ * text that follows the LAST occurrence of one of these and discards everything
240
+ * before it, which is what removes the absolute prefix. A frame with no such
241
+ * segment is not ours, so it is dropped.
242
+ */
243
+ const BUNDLE_ANCHORS = [
244
+ "/out/",
245
+ "/assets/",
246
+ "/review-runtime/"
247
+ ];
248
+ /** `at fn (url:line:col)`, `at url:line:col`, and the bare `url:line:col`. */
249
+ const STACK_FRAME_PATTERN = /(?:\(|\bat\s+|^\s*)([^()\s]+?):(\d+):(\d+)\)?\s*$/;
250
+ /**
251
+ * `cause` is the raw error envelope a client attached beside its event
252
+ * properties: `{ name, message, stack }` as JSON, or anything else a hostile
253
+ * client sent, which derives nothing.
254
+ */
255
+ function deriveErrorTelemetryProperties(cause) {
256
+ const derived = {};
257
+ try {
258
+ if (!isJsonObject(cause)) return derived;
259
+ const name = jsonString(cause.name);
260
+ if (name !== void 0 && name.length > 0 && name.length <= MAX_ERROR_NAME_LENGTH && ERROR_NAME_PATTERN.test(name)) derived.error_name = name;
261
+ const message = jsonString(cause.message);
262
+ if (message !== void 0 && message.length > 0) {
263
+ derived.message_hash = hashErrorMessage(message);
264
+ if (!SCHEMA_ERROR_NAMES.has(derived.error_name ?? "")) {
265
+ const cleaned = cleanTelemetryText(message, NO_DELETED_DIRECTORIES);
266
+ if (isReportableCleanedMessage(cleaned)) derived.message = cleaned;
267
+ }
268
+ }
269
+ const frames = packBundleFrames(cause.stack);
270
+ if (frames) derived.frames = frames;
271
+ } catch {}
272
+ return derived;
273
+ }
274
+ /**
275
+ * Properties only this module may produce, because each is a claim about work
276
+ * the server did that the allowlist cannot check for itself: whether a message
277
+ * was cleaned, and whether a digest or a frame list really came from this
278
+ * error. A client that sent one directly would reach the allowlist with no
279
+ * cleaning behind it.
280
+ *
281
+ * `error_name` is deliberately absent. The allowlist caps it at 40 identifier
282
+ * characters, which makes it safe whoever sends it, and `bug_report_send_failed`
283
+ * sends it with no error envelope.
284
+ */
285
+ const SERVER_DERIVED_PROPERTIES = [
286
+ "message",
287
+ "message_hash",
288
+ "frames"
289
+ ];
290
+ /**
291
+ * Merge a client's event properties with the ones derived from its raw error.
292
+ * This is the only supported way to build an error-bearing telemetry payload:
293
+ * it removes any server-derived property the client tried to assert before
294
+ * adding the real ones.
295
+ */
296
+ function mergeErrorTelemetryProperties(clientProperties, cause) {
297
+ const merged = { ...clientProperties };
298
+ for (const key of SERVER_DERIVED_PROPERTIES) delete merged[key];
299
+ if (cause) Object.assign(merged, deriveErrorTelemetryProperties(cause));
300
+ return merged;
301
+ }
302
+ /**
303
+ * Review deliberately gives the cleaner NO directories to delete, which is the
304
+ * one place its configuration differs from VS Code's.
305
+ *
306
+ * VS Code passes five — its app root, extensions path, user data path, home
307
+ * directory and temporary directory — and deletes each prefix outright so the
308
+ * remainder reads as a relative path. That is right for a stack trace, where
309
+ * the remainder is VS Code's own file. It is wrong here: a path under the home
310
+ * directory then keeps everything after it, so
311
+ * `/Users/you/work/acme-repo/plan.md` would be sent as
312
+ * `/work/acme-repo/plan.md` — the repository name intact. A review tool cannot
313
+ * disclose that.
314
+ *
315
+ * With no directories to delete, the cleaner's overlap check never fires and
316
+ * the whole path is replaced by a marker instead, which is what we want. Frames
317
+ * are unaffected: they are anchored on the bundle directory separately.
318
+ */
319
+ const NO_DELETED_DIRECTORIES = [];
320
+ /**
321
+ * A stable fingerprint of the message. Two reports with the same digest had the
322
+ * same cause; the digest itself reveals nothing, because it is one-way and the
323
+ * message is never sent alongside it.
324
+ */
325
+ function hashErrorMessage(message) {
326
+ return createHash("sha256").update(message, "utf8").digest("hex").slice(0, MESSAGE_HASH_LENGTH);
327
+ }
328
+ /**
329
+ * Rewrite a stack into bundle-relative `file:line:col` frames joined by "|".
330
+ * Returns undefined when no frame resolves inside the bundle.
331
+ */
332
+ function packBundleFrames(stack) {
333
+ const text = Array.isArray(stack) ? stack.join("\n") : jsonString(stack);
334
+ if (!text) return void 0;
335
+ const frames = [];
336
+ for (const line of text.slice(0, MAX_STACK_LENGTH).split("\n")) {
337
+ const match = STACK_FRAME_PATTERN.exec(line);
338
+ if (!match) continue;
339
+ const file = bundleRelativePath(match[1]);
340
+ if (!file) continue;
341
+ const frame = `${file}:${match[2]}:${match[3]}`;
342
+ if (!BUNDLE_FRAME_PATTERN.test(frame)) continue;
343
+ frames.push(frame);
344
+ if (frames.length >= MAX_FRAMES) break;
345
+ }
346
+ return frames.length > 0 ? frames.join("|") : void 0;
347
+ }
348
+ function bundleRelativePath(location) {
349
+ const clean = location.split(/[?#]/, 1)[0];
350
+ let cut = -1;
351
+ let anchorLength = 0;
352
+ for (const anchor of BUNDLE_ANCHORS) {
353
+ const index = clean.lastIndexOf(anchor);
354
+ if (index > cut) {
355
+ cut = index;
356
+ anchorLength = anchor.length;
357
+ }
358
+ }
359
+ if (cut < 0) return void 0;
360
+ const relative = clean.slice(cut + anchorLength);
361
+ const prefix = clean.slice(cut + 1, cut + anchorLength);
362
+ const path = prefix === "out/" ? relative : `${prefix}${relative}`;
363
+ return path.length > 0 ? path : void 0;
364
+ }
365
+ const BURST_EVERY = 100;
366
+ const DEFAULT_MAX_KEYS = 1e3;
367
+ var ClientErrorBudget = class {
368
+ maxKeys;
369
+ counts = /* @__PURE__ */ new Map();
370
+ constructor(maxKeys = DEFAULT_MAX_KEYS) {
371
+ this.maxKeys = maxKeys;
372
+ }
373
+ admit(sessionId, messageHash) {
374
+ const key = `${sessionId}\n${messageHash}`;
375
+ const count = (this.counts.get(key) ?? 0) + 1;
376
+ this.counts.delete(key);
377
+ this.counts.set(key, count);
378
+ while (this.counts.size > this.maxKeys) {
379
+ const oldest = this.counts.keys().next().value;
380
+ if (oldest === void 0) break;
381
+ this.counts.delete(oldest);
382
+ }
383
+ const suppressed = Math.max(0, count - 5);
384
+ if (suppressed === 0) return {
385
+ verdict: "send",
386
+ suppressed
387
+ };
388
+ return {
389
+ verdict: suppressed === 1 || suppressed % BURST_EVERY === 0 ? "burst" : "drop",
390
+ suppressed
391
+ };
392
+ }
393
+ };
394
+ //#endregion
395
+ //#region src/server/ui-telemetry.ts
396
+ const contextSchema = z.object({
397
+ reviewUuid: z.string().min(1).max(128).optional(),
398
+ presentationSessionId: z.string().min(1).max(128).optional()
399
+ });
400
+ const clientErrorBudget = new ClientErrorBudget();
401
+ /**
402
+ * Applies the per-session, per-digest error budget to a sanitized event, and
403
+ * returns what to send in its place: the event itself, one
404
+ * review_error_burst that says how many were withheld, or nothing. Every path
405
+ * that reports review_client_error goes through here. Other events, and errors
406
+ * without a session or a digest, pass unchanged.
407
+ */
408
+ function admitUiTelemetryEvent(event) {
409
+ if (event.event !== "review_client_error") return event;
410
+ const sessionId = jsonString(event.properties.app_session_id);
411
+ const messageHash = jsonString(event.properties.message_hash);
412
+ if (sessionId === void 0 || messageHash === void 0) return event;
413
+ const admission = clientErrorBudget.admit(sessionId, messageHash);
414
+ if (admission.verdict === "send") return event;
415
+ if (admission.verdict === "drop") return void 0;
416
+ return sanitizeUiTelemetryEvent({
417
+ name: "error_burst",
418
+ properties: {
419
+ message_hash: messageHash,
420
+ suppressed: admission.suppressed,
421
+ app_session_id: sessionId
422
+ }
423
+ }) ?? void 0;
424
+ }
425
+ const MAX_CLIENT_ERROR_SESSIONS = 100;
426
+ const MAX_CLIENT_ERRORS_PER_SESSION = 20;
427
+ const clientErrorsBySession = /* @__PURE__ */ new Map();
428
+ function recordClientError(event) {
429
+ if (event?.event !== "review_client_error") return;
430
+ const sessionId = jsonString(event.properties.app_session_id);
431
+ const errorName = jsonString(event.properties.error_name);
432
+ if (sessionId === void 0 || errorName === void 0) return;
433
+ const names = clientErrorsBySession.get(sessionId) ?? [];
434
+ names.push(errorName);
435
+ if (names.length > MAX_CLIENT_ERRORS_PER_SESSION) names.shift();
436
+ clientErrorsBySession.delete(sessionId);
437
+ clientErrorsBySession.set(sessionId, names);
438
+ while (clientErrorsBySession.size > MAX_CLIENT_ERROR_SESSIONS) {
439
+ const oldest = clientErrorsBySession.keys().next().value;
440
+ if (oldest === void 0) break;
441
+ clientErrorsBySession.delete(oldest);
442
+ }
443
+ }
444
+ function clientErrorsForSession(sessionId) {
445
+ const names = clientErrorsBySession.get(sessionId) ?? [];
446
+ if (names.length > 0) {
447
+ clientErrorsBySession.delete(sessionId);
448
+ clientErrorsBySession.set(sessionId, names);
449
+ }
450
+ return [...names];
451
+ }
452
+ /** How far back a client may date its own event. */
453
+ const MAX_CLIENT_EVENT_AGE_MS = 3e5;
454
+ /**
455
+ * The client's occurrence time, clamped to the recent past: parallel requests
456
+ * can arrive out of order, but a client can never date an event ahead of now
457
+ * or far behind it.
458
+ */
459
+ function clientOccurredAt(raw, now) {
460
+ const parsed = z.number().safeParse(raw);
461
+ if (!parsed.success) return now;
462
+ return Math.min(now, Math.max(now - MAX_CLIENT_EVENT_AGE_MS, parsed.data));
463
+ }
464
+ async function captureSanitizedUiTelemetry(telemetry, request, name, properties, onSanitized, rawError, rawContext, rawOccurredAt) {
465
+ const occurredAt = clientOccurredAt(rawOccurredAt, Date.now());
466
+ const appSessionId = request.headers.get("x-review-app-session-id") ?? void 0;
467
+ const mergedProperties = mergeErrorTelemetryProperties(isJsonObject(properties) ? properties : {}, rawError);
468
+ if (appSessionId) mergedProperties.app_session_id = appSessionId;
469
+ const sanitized = sanitizeUiTelemetryEvent({
470
+ name,
471
+ properties: mergedProperties
472
+ });
473
+ if (!sanitized) return;
474
+ const admitted = admitUiTelemetryEvent(sanitized);
475
+ if (!admitted) return;
476
+ const parsedContext = contextSchema.safeParse(rawContext);
477
+ const context = parsedContext.success ? parsedContext.data : void 0;
478
+ onSanitized?.(admitted, context);
479
+ try {
480
+ await telemetry.captureUiEvent?.(admitted.event, admitted.properties, context, occurredAt);
481
+ } catch (error) {
482
+ console.error(error);
483
+ }
484
+ }
485
+ //#endregion
486
+ //#region src/ask/pi-mcp.ts
487
+ /**
488
+ * pi-acp keeps the MCP servers a session is given without passing them to
489
+ * Pi. Pi 0.99.0 and later take them from an extension, so Ask starts Pi
490
+ * through a wrapper that loads one, registering the servers for that session
491
+ * only. Older Pi, and Windows, which would need a different wrapper, answer
492
+ * without them.
493
+ */
494
+ /** The first Pi with `pi.registerMcpServer()`. */
495
+ const PI_MCP_VERSION = [
496
+ 0,
497
+ 99,
498
+ 0
499
+ ];
500
+ /** How long a version read is trusted: a session's first prompt and its
501
+ * launch ask moments apart, and an update during a review is seen soon. */
502
+ const VERSION_TTL_MS = 3e4;
503
+ const SERVERS_ENV = "WHITEBOARD_ASK_MCP";
504
+ const PI_ENV = "WHITEBOARD_ASK_PI";
505
+ const EXTENSION_ENV = "WHITEBOARD_ASK_PI_EXTENSION";
506
+ const EXTENSION = `// Managed by Whiteboard: gives a Pi session that Ask starts the MCP servers
507
+ // Whiteboard hands it. Do not edit.
508
+ export default function (pi: {
509
+ registerMcpServer(name: string, config: object): void;
510
+ }) {
511
+ const servers = JSON.parse(process.env.${SERVERS_ENV} ?? "{}");
512
+
513
+ for (const [name, config] of Object.entries(servers))
514
+ pi.registerMcpServer(name, config as object);
515
+ }
516
+ `;
517
+ const WRAPPER = `#!/bin/sh
518
+ # Managed by Whiteboard: starts Pi for Ask with Whiteboard's extension.
519
+ exec "$${PI_ENV}" -e "$${EXTENSION_ENV}" "$@"
520
+ `;
521
+ const versions = /* @__PURE__ */ new Map();
522
+ function piVersion(executable) {
523
+ const cached = versions.get(executable);
524
+ if (cached && Date.now() - cached.read < VERSION_TTL_MS) return cached.version;
525
+ const version = promisify(execFile)(executable, ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+)\.(\d+)\.(\d+)/u.exec(stdout)?.slice(1).map(Number)).catch(() => void 0);
526
+ versions.set(executable, {
527
+ read: Date.now(),
528
+ version
529
+ });
530
+ return version;
531
+ }
532
+ /** Whether this Pi can be given MCP servers. */
533
+ async function piTakesMcp(executable) {
534
+ if (process.platform === "win32") return false;
535
+ const version = await piVersion(executable);
536
+ if (!version) return false;
537
+ for (const [index, least] of PI_MCP_VERSION.entries()) {
538
+ const part = version[index] ?? 0;
539
+ if (part !== least) return part > least;
540
+ }
541
+ return true;
542
+ }
543
+ /** Writes a file only when it changed, replacing it whole, since another
544
+ * Ask session may be running it. */
545
+ async function keep(file, content, mode) {
546
+ if (await readFile(file, "utf8").catch(() => void 0) === content) return;
547
+ const staged = `${file}.${process.pid}.tmp`;
548
+ await writeFile(staged, content, { mode });
549
+ await chmod(staged, mode);
550
+ await rename(staged, file);
551
+ }
552
+ function piServers(servers) {
553
+ const piServers = {};
554
+ const record = (pairs) => Object.fromEntries(pairs.map(({ name, value }) => [name, value]));
555
+ for (const server of servers) if ("command" in server) piServers[server.name] = {
556
+ command: server.command,
557
+ args: server.args,
558
+ env: record(server.env),
559
+ exposure: "direct"
560
+ };
561
+ else if (server.type === "http") piServers[server.name] = {
562
+ url: server.url,
563
+ headers: record(server.headers),
564
+ exposure: "direct"
565
+ };
566
+ return piServers;
567
+ }
568
+ /** The command pi-acp runs in place of Pi: Pi with the extension that
569
+ * registers `servers`, which go to it in `env`. */
570
+ async function piWithMcp(executable, servers, env) {
571
+ const directory = path.join(devReviewHome(), "ask", "pi");
572
+ const extension = path.join(directory, "whiteboard-mcp.ts");
573
+ const wrapper = path.join(directory, "pi");
574
+ await mkdir(directory, { recursive: true });
575
+ await keep(extension, EXTENSION, 420);
576
+ await keep(wrapper, WRAPPER, 493);
577
+ env[PI_ENV] = executable;
578
+ env[EXTENSION_ENV] = extension;
579
+ env[SERVERS_ENV] = JSON.stringify(piServers(servers));
580
+ return wrapper;
581
+ }
582
+ //#endregion
583
+ //#region src/ask/agents.ts
584
+ const askAgents = {
585
+ claude: {
586
+ name: "Claude Code",
587
+ commands: ["claude"],
588
+ launch: {
589
+ adapter: "@agentclientprotocol/claude-agent-acp/dist/index.js",
590
+ executableEnv: "CLAUDE_CODE_EXECUTABLE"
591
+ },
592
+ signIn: "claude auth login",
593
+ readOnlyMode: "default",
594
+ sessionMeta: { claudeCode: { options: {
595
+ disallowedTools: [
596
+ "Edit",
597
+ "MultiEdit",
598
+ "Write",
599
+ "NotebookEdit",
600
+ "EnterPlanMode",
601
+ "ExitPlanMode"
602
+ ],
603
+ allowDangerouslySkipPermissions: false
604
+ } } },
605
+ bypass: {
606
+ mode: "bypassPermissions",
607
+ sessionMeta: { claudeCode: { options: { disallowedTools: ["EnterPlanMode", "ExitPlanMode"] } } }
608
+ }
609
+ },
610
+ codex: {
611
+ name: "Codex",
612
+ commands: ["codex"],
613
+ launch: {
614
+ adapter: "@agentclientprotocol/codex-acp/dist/index.js",
615
+ executableEnv: "CODEX_PATH"
616
+ },
617
+ signIn: "codex login",
618
+ readOnlyMode: "read-only",
619
+ bypass: { mode: "agent-full-access" }
620
+ },
621
+ cursor: {
622
+ name: "Cursor",
623
+ commands: ["cursor-agent"],
624
+ installDirs: [".local/bin"],
625
+ launch: { args: ["acp"] },
626
+ signIn: "cursor-agent login",
627
+ readOnlyMode: "ask",
628
+ bypass: { mode: "agent" }
629
+ },
630
+ opencode: {
631
+ name: "OpenCode",
632
+ commands: ["opencode"],
633
+ installDirs: [".opencode/bin"],
634
+ launch: { args: ["acp"] },
635
+ signIn: "opencode auth login",
636
+ readOnlyMode: "build",
637
+ env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: {
638
+ edit: "deny",
639
+ bash: "ask",
640
+ webfetch: "ask"
641
+ } }) },
642
+ bypass: {
643
+ mode: "build",
644
+ env: { OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: {
645
+ edit: "allow",
646
+ bash: "allow",
647
+ webfetch: "allow"
648
+ } }) }
649
+ }
650
+ },
651
+ pi: {
652
+ name: "Pi",
653
+ commands: ["pi"],
654
+ launch: {
655
+ adapter: "pi-acp/dist/index.js",
656
+ executableEnv: "PI_ACP_PI_COMMAND"
657
+ },
658
+ signIn: "pi",
659
+ mcp: {
660
+ supported: piTakesMcp,
661
+ launch: piWithMcp
662
+ }
663
+ }
664
+ };
665
+ /** Desktop inherits the login shell's PATH, so this sees what a terminal sees.
666
+ * Package-manager bin directories are skipped: the adapters' own dependencies
667
+ * put `codex` there, and that is not the user's install. */
668
+ async function findExecutable(command, env = process.env) {
669
+ for (const directory of (env.PATH ?? "").split(path.delimiter)) {
670
+ if (!directory || directory.split(path.sep).includes("node_modules")) continue;
671
+ const candidate = path.join(directory, command);
672
+ try {
673
+ if (!(await stat(candidate)).isFile()) continue;
674
+ await access(candidate, constants.X_OK);
675
+ return candidate;
676
+ } catch {}
677
+ }
678
+ }
679
+ /** The user's install of an agent's CLI: on PATH, else where its installer
680
+ * puts it. */
681
+ async function findAgent(spec, env = process.env) {
682
+ const installed = (spec.installDirs ?? []).map((directory) => path.join(homedir(), directory));
683
+ for (const command of spec.commands) {
684
+ const found = await findExecutable(command, env) ?? await findExecutable(command, { PATH: installed.join(path.delimiter) });
685
+ if (found) return found;
686
+ }
687
+ }
688
+ /** Whether the agent's model gets the MCP servers its sessions are given. */
689
+ async function askAgentTakesMcp(agent) {
690
+ const spec = askAgents[agent];
691
+ if (!spec.mcp) return true;
692
+ const executable = await findAgent(spec);
693
+ return executable !== void 0 && spec.mcp.supported(executable);
694
+ }
695
+ async function detectAskAgents(env = process.env) {
696
+ return Promise.all(askAgentIds.map(async (id) => ({
697
+ id,
698
+ name: askAgents[id].name,
699
+ available: await findAgent(askAgents[id], env) !== void 0,
700
+ readOnly: askAgents[id].readOnlyMode !== void 0,
701
+ bypass: askAgents[id].bypass !== void 0
702
+ })));
703
+ }
704
+ /** Runs an adapter with this server's runtime, Desktop's being Electron, or
705
+ * the user's CLI itself. */
706
+ const launchAskAgent = async (agent, cwd, { bypass = false, mcpServers = [] } = {}) => {
707
+ const spec = askAgents[agent];
708
+ const executable = await findAgent(spec);
709
+ if (!executable) throw new Error(`${spec.name} is not installed.`);
710
+ const env = {
711
+ ...process.env,
712
+ ...bypass && spec.bypass?.env || spec.env
713
+ };
714
+ delete env.CLAUDECODE;
715
+ delete env.CLAUDE_CODE_ENTRYPOINT;
716
+ const { launch } = spec;
717
+ let command = executable;
718
+ let args;
719
+ if ("adapter" in launch) {
720
+ env[launch.executableEnv] = spec.mcp && mcpServers.length && await spec.mcp.supported(executable) ? await spec.mcp.launch(executable, mcpServers, env) : executable;
721
+ if (process.versions.electron) env.ELECTRON_RUN_AS_NODE = "1";
722
+ command = process.execPath;
723
+ args = [createRequire(import.meta.url).resolve(launch.adapter)];
724
+ } else args = launch.args;
725
+ const child = spawn(command, args, {
726
+ cwd,
727
+ env,
728
+ stdio: [
729
+ "pipe",
730
+ "pipe",
731
+ "pipe"
732
+ ]
733
+ });
734
+ let stderr = "";
735
+ child.stderr.setEncoding("utf8");
736
+ child.stderr.on("data", (chunk) => {
737
+ stderr = (stderr + chunk).slice(-8e3);
738
+ });
739
+ await new Promise((resolve, reject) => {
740
+ child.once("spawn", resolve);
741
+ child.once("error", reject);
742
+ });
743
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
744
+ return {
745
+ connect: (client) => {
746
+ const connection = client.connect(stream);
747
+ child.once("exit", () => connection.close());
748
+ return connection;
749
+ },
750
+ diagnostics: () => stderr,
751
+ stop: () => {
752
+ if (child.exitCode === null) child.kill();
753
+ }
754
+ };
755
+ };
756
+ //#endregion
757
+ //#region src/ask/protocol.ts
758
+ /** Claude's adapter names the MCP server behind an `mcp__*` permission here. */
759
+ const claudeMcpMetaSchema = z.object({ claudeCode: z.object({ mcpServer: z.object({ name: z.string() }) }) });
760
+ /** Codex's adapter puts the server in an MCP tool call's input. */
761
+ const mcpInputSchema = z.union([z.object({ server: z.string() }), z.object({ serverName: z.string() })]);
762
+ /** The MCP server a tool call runs on, if the adapter says. */
763
+ function mcpServerOf(toolCall) {
764
+ const claude = claudeMcpMetaSchema.safeParse(toolCall._meta).data;
765
+ if (claude) return claude.claudeCode.mcpServer.name;
766
+ const input = mcpInputSchema.safeParse(toolCall.rawInput).data;
767
+ return input && ("server" in input ? input.server : input.serverName);
768
+ }
769
+ /** Wraps the selection context in the first prompt, so a replayed
770
+ * conversation can show the question without it. */
771
+ const CONTEXT_OPEN = "<whiteboard-context>";
772
+ const CONTEXT_CLOSE = "</whiteboard-context>";
773
+ /** A conversation closed mid-turn, as it should look when reopened: the
774
+ * agent stopped, so nothing is still running or waiting on the reviewer. */
775
+ function settled(entries) {
776
+ return entries.map((entry) => {
777
+ if (entry.kind === "permission" && entry.outcome === void 0) return {
778
+ ...entry,
779
+ outcome: "cancelled"
780
+ };
781
+ if (entry.kind === "tool" && (entry.status === "pending" || entry.status === "in_progress")) return {
782
+ ...entry,
783
+ status: "failed"
784
+ };
785
+ return entry;
786
+ });
787
+ }
788
+ /** Enough of a tool's output to see what came back. */
789
+ const OUTPUT_LIMIT = 4e3;
790
+ const selectOptionSchema = z.object({
791
+ value: z.string(),
792
+ name: z.string(),
793
+ description: z.string().nullish()
794
+ });
795
+ /** A select's choices, flat or in groups. */
796
+ const selectOptionsSchema = z.array(z.union([selectOptionSchema, z.object({
797
+ group: z.string(),
798
+ options: z.array(selectOptionSchema)
799
+ })]));
800
+ /** The ACP config option category each choice comes from. */
801
+ const choiceCategories = /* @__PURE__ */ new Map([["model", "model"], ["effort", "thought_level"]]);
802
+ const effortNames = /* @__PURE__ */ new Map([["xhigh", "Extra high"]]);
803
+ /** What the agent offers of each choice, from its session config options,
804
+ * with the config option that sets it. */
805
+ function choicesOf(options) {
806
+ const found = /* @__PURE__ */ new Map();
807
+ for (const kind of askChoiceKinds) {
808
+ const option = options?.find((candidate) => candidate.type === "select" && (candidate.category === choiceCategories.get(kind) || candidate.id === kind));
809
+ if (option?.type !== "select") continue;
810
+ const choices = selectOptionsSchema.safeParse(option.options).data;
811
+ if (!choices) continue;
812
+ found.set(kind, {
813
+ configId: option.id,
814
+ select: {
815
+ current: option.currentValue,
816
+ options: choices.flatMap((choice) => "group" in choice ? choice.options : [choice]).map(({ value, name: offered, description }) => {
817
+ const name = kind === "effort" && effortNames.get(value) || offered;
818
+ return description ? {
819
+ value,
820
+ name,
821
+ description
822
+ } : {
823
+ value,
824
+ name
825
+ };
826
+ })
827
+ }
828
+ });
829
+ }
830
+ return found;
831
+ }
832
+ /** A slash command as the panel offers it. */
833
+ function commandOf({ name, description, input }) {
834
+ return input?.hint ? {
835
+ name,
836
+ description,
837
+ hint: input.hint
838
+ } : {
839
+ name,
840
+ description
841
+ };
842
+ }
843
+ /** What a question carries, as its entry shows it. */
844
+ function attachmentsOf({ mentions = [], images = [] }) {
845
+ return [...mentions.map((mention) => ({
846
+ kind: "file",
847
+ path: mention
848
+ })), ...images.map(({ name }) => ({
849
+ kind: "image",
850
+ name
851
+ }))];
852
+ }
853
+ const toolInputSchema = z.object({
854
+ command: z.union([z.string(), z.array(z.string())]).optional(),
855
+ description: z.string().optional(),
856
+ file_path: z.string().optional(),
857
+ path: z.string().optional(),
858
+ query: z.string().optional(),
859
+ pattern: z.string().optional(),
860
+ patterns: z.array(z.string()).optional()
861
+ });
862
+ /** What a tool call ran or opened, and why, from what the adapter sent. */
863
+ function toolDetails(update) {
864
+ const input = toolInputSchema.safeParse(update.rawInput).data;
865
+ const command = input?.command;
866
+ const target = (Array.isArray(command) ? command.join(" ") : command) ?? input?.file_path ?? input?.path ?? input?.query ?? input?.pattern ?? input?.patterns?.join(" | ") ?? update.locations?.[0]?.path;
867
+ const details = {};
868
+ if (target) details.input = target;
869
+ if (input?.description) details.summary = input.description;
870
+ return details;
871
+ }
872
+ /** A finished tool's text output. Claude fences a command's output. */
873
+ function toolOutput(update) {
874
+ const text = (update.content ?? []).flatMap((item) => item.type === "content" && item.content.type === "text" ? [item.content.text] : []).join("\n").replace(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/u, "$1").trimEnd();
875
+ if (!text) return void 0;
876
+ return text.length > OUTPUT_LIMIT ? `${text.slice(0, OUTPUT_LIMIT)}\n…` : text;
877
+ }
878
+ /** A first question, after the selection it is about. */
879
+ function withContext(context, question) {
880
+ return [{
881
+ type: "text",
882
+ text: `${CONTEXT_OPEN}\n${context}\n${CONTEXT_CLOSE}`
883
+ }, {
884
+ type: "text",
885
+ text: question
886
+ }];
887
+ }
888
+ /** A replayed first message, as the reviewer typed it. */
889
+ function withoutContext(text) {
890
+ const start = text.indexOf(CONTEXT_OPEN);
891
+ if (start === -1) return text.trim();
892
+ const end = text.indexOf(CONTEXT_CLOSE, start);
893
+ return (text.slice(0, start) + (end === -1 ? "" : text.slice(end + 21))).trim();
894
+ }
895
+ /** ACP's "authentication required": the agent's login lapsed or never was.
896
+ * Some adapters send the same code for other failures, so its message has
897
+ * to say so too. */
898
+ function signedOut(error) {
899
+ return error instanceof RequestError && error.code === RequestError.authRequired().code && /auth/i.test(error.message);
900
+ }
901
+ //#endregion
902
+ //#region src/ask/thread.ts
903
+ /** Refused without asking: changes to files, and leaving the read-only
904
+ * mode (Claude asks to exit plan mode with its plan). */
905
+ const REFUSED_KINDS = /* @__PURE__ */ new Set([
906
+ "edit",
907
+ "delete",
908
+ "move",
909
+ "switch_mode"
910
+ ]);
911
+ const piGreetingSchema = z.object({ piAcp: z.object({ startupInfo: z.string() }) });
912
+ const cursorTodosSchema = z.object({ todos: z.unknown() }).loose();
913
+ /** MCP servers the reviewer's own agent may bring whose tools only read,
914
+ * so their calls run without asking, like the agent reading files. */
915
+ const READ_ONLY_MCP_SERVERS = /* @__PURE__ */ new Set(["fff"]);
916
+ const askThreadLimits = {
917
+ startMs: 6e4,
918
+ stopGraceMs: 15e3
919
+ };
920
+ /** One Ask conversation: an agent process and one ACP session in the review's checkout. */
921
+ var AskThread = class {
922
+ launch;
923
+ start;
924
+ limits;
925
+ id;
926
+ state;
927
+ /** The number of changes so far; a snapshot carries the one it includes. */
928
+ seq = 0;
929
+ listeners = /* @__PURE__ */ new Set();
930
+ closers = /* @__PURE__ */ new Set();
931
+ decisions = /* @__PURE__ */ new Map();
932
+ process;
933
+ connection;
934
+ /** Counts connections, so a start that was given up on cannot take over. */
935
+ generation = 0;
936
+ /** What the last agent process said on stderr, once it is gone. */
937
+ stderr = "";
938
+ sessionId;
939
+ closed = false;
940
+ /** The MCP server behind each tool call an adapter reported one for. */
941
+ mcpCalls = /* @__PURE__ */ new Map();
942
+ mcpServers;
943
+ /** Loading an earlier conversation: the agent replays it as updates. */
944
+ replaying = false;
945
+ /** Replayed user messages as sent, context included. */
946
+ replayedUser = /* @__PURE__ */ new Map();
947
+ /** The panel already shows the conversation, so a load's replay is not
948
+ * needed: a saved copy, or the thread itself when it tries again. */
949
+ shown;
950
+ /** The config option that sets each choice the agent offers. */
951
+ configIds = /* @__PURE__ */ new Map();
952
+ /** The agent's other settings as last seen, to say when it changes one
953
+ * itself, as Codex's /plan does. */
954
+ settings = /* @__PURE__ */ new Map();
955
+ /** The model and effort in use, which a new agent process starts with. */
956
+ picks;
957
+ /** The question being answered, until its turn ends; after a failure,
958
+ * the one trying again asks. */
959
+ asking;
960
+ /** The session has not had the selection yet: a new conversation, or a
961
+ * new session for one the agent could not reopen. */
962
+ needsContext;
963
+ /** The reviewer changed what the agent may do since it was last told. */
964
+ permitted = false;
965
+ /** The agent could not reopen the session, for a reason signing in would
966
+ * not fix, so trying again starts a new one. */
967
+ unloadable = false;
968
+ /** Stop ended the agent itself: a start, or a turn that would not stop. */
969
+ halted = false;
970
+ stopTimer;
971
+ /** What Pi greets a new session with, its version and skills, which is no
972
+ * part of the answer. */
973
+ greeting;
974
+ constructor(launch, start, mcpServers = () => [], limits = askThreadLimits) {
975
+ this.launch = launch;
976
+ this.start = start;
977
+ this.limits = limits;
978
+ this.id = start.id ?? randomUUID();
979
+ this.mcpServers = mcpServers();
980
+ this.picks = { ...start.picks };
981
+ this.state = {
982
+ id: this.id,
983
+ agent: start.agent,
984
+ agentName: askAgents[start.agent].name,
985
+ status: "starting",
986
+ readOnly: false,
987
+ bypass: start.bypass ?? false,
988
+ head: start.head,
989
+ cwd: start.cwd,
990
+ selection: start.selection,
991
+ entries: "resume" in start && start.resume.entries || []
992
+ };
993
+ if ("resume" in start) this.sessionId = start.resume.sessionId;
994
+ this.shown = "resume" in start && Boolean(start.resume.entries);
995
+ this.needsContext = !("resume" in start);
996
+ }
997
+ get reviewId() {
998
+ return this.start.reviewId;
999
+ }
1000
+ read() {
1001
+ return this.state;
1002
+ }
1003
+ snapshot() {
1004
+ return {
1005
+ seq: this.seq,
1006
+ snapshot: this.state
1007
+ };
1008
+ }
1009
+ /** Every change after the current `seq`, in order. */
1010
+ subscribe(listener) {
1011
+ this.listeners.add(listener);
1012
+ return () => this.listeners.delete(listener);
1013
+ }
1014
+ /** Starts the agent and asks the first question, or loads an earlier
1015
+ * conversation; failures land in the state. */
1016
+ async open() {
1017
+ const start = this.start;
1018
+ if ("resume" in start) {
1019
+ await this.attempt(async () => {
1020
+ await this.connect();
1021
+ this.emit({
1022
+ type: "set",
1023
+ status: "idle"
1024
+ });
1025
+ });
1026
+ return;
1027
+ }
1028
+ this.asking = {
1029
+ id: this.addUser(start.question),
1030
+ question: start.question
1031
+ };
1032
+ await this.attempt(async () => {
1033
+ await this.connect();
1034
+ await this.prompt(start.question);
1035
+ });
1036
+ }
1037
+ /** Why a question cannot be asked now, if it cannot. */
1038
+ askRefusal() {
1039
+ switch (this.state.status) {
1040
+ case "idle": return;
1041
+ case "failed": return `${this.state.agentName} stopped. Try again first.`;
1042
+ default: return "The agent is still answering.";
1043
+ }
1044
+ }
1045
+ /** Asks a follow-up, starting the agent again if it has stopped since. */
1046
+ async ask(question) {
1047
+ const refusal = this.askRefusal();
1048
+ if (refusal) throw new Error(refusal);
1049
+ this.asking = {
1050
+ id: this.addUser(question),
1051
+ question
1052
+ };
1053
+ await this.attempt(async () => {
1054
+ await this.reconnect();
1055
+ await this.prompt(question);
1056
+ });
1057
+ }
1058
+ /** Starts the agent again after it failed, as when its login lapsed, and
1059
+ * asks again the question it did not answer, if one failed. */
1060
+ async retry() {
1061
+ if (this.state.status !== "failed") throw new Error("Only a conversation that failed can try again.");
1062
+ this.disconnect();
1063
+ const asked = this.state.entries.findIndex((entry) => entry.kind === "user" && entry.id === this.asking?.id);
1064
+ const question = asked === -1 ? void 0 : this.asking?.question;
1065
+ const partial = asked === -1 ? [] : this.state.entries.slice(asked + 1);
1066
+ if (partial.length) this.emit({
1067
+ type: "remove",
1068
+ ids: partial.map((entry) => entry.id)
1069
+ });
1070
+ if (this.unloadable) {
1071
+ this.unloadable = false;
1072
+ this.sessionId = void 0;
1073
+ this.needsContext = true;
1074
+ }
1075
+ this.emit({
1076
+ type: "set",
1077
+ status: "starting",
1078
+ error: null,
1079
+ signIn: null
1080
+ });
1081
+ await this.attempt(async () => {
1082
+ await this.connect();
1083
+ if (question) await this.prompt(question);
1084
+ else this.emit({
1085
+ type: "set",
1086
+ status: "idle"
1087
+ });
1088
+ });
1089
+ }
1090
+ decide(permissionId, optionId) {
1091
+ const resolve = this.decisions.get(permissionId);
1092
+ if (!resolve) return false;
1093
+ resolve({ outcome: {
1094
+ outcome: "selected",
1095
+ optionId
1096
+ } });
1097
+ return true;
1098
+ }
1099
+ /** Stops the turn, or the start, in progress. A turn the agent does not
1100
+ * end soon after is ended by stopping the agent. */
1101
+ async cancel() {
1102
+ for (const resolve of this.decisions.values()) resolve({ outcome: { outcome: "cancelled" } });
1103
+ const { status } = this.state;
1104
+ if (status === "starting") {
1105
+ this.halt();
1106
+ return;
1107
+ }
1108
+ const { connection, sessionId } = this;
1109
+ if (status !== "running" && status !== "waiting" || !connection) return;
1110
+ clearTimeout(this.stopTimer);
1111
+ this.stopTimer = setTimeout(() => {
1112
+ if (this.connection === connection) this.halt();
1113
+ }, this.limits.stopGraceMs);
1114
+ if (sessionId) await connection.agent.notify(methods.agent.session.cancel, { sessionId }).catch(() => {});
1115
+ }
1116
+ close() {
1117
+ if (this.closed) return;
1118
+ this.closed = true;
1119
+ if (this.sessionId && this.state.status !== "starting" && this.state.status !== "failed") this.start.onSave?.(settled(this.state.entries));
1120
+ for (const resolve of this.decisions.values()) resolve({ outcome: { outcome: "cancelled" } });
1121
+ if (this.connection && this.sessionId) this.connection.agent.notify(methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {});
1122
+ this.disconnect();
1123
+ this.listeners.clear();
1124
+ for (const closed of this.closers) closed();
1125
+ this.closers.clear();
1126
+ }
1127
+ /** Runs when the thread closes, at once if it already has. */
1128
+ onClose(closed) {
1129
+ if (this.closed) {
1130
+ closed();
1131
+ return () => {};
1132
+ }
1133
+ this.closers.add(closed);
1134
+ return () => this.closers.delete(closed);
1135
+ }
1136
+ /** Runs a step that talks to the agent; failures land in the state. */
1137
+ async attempt(step) {
1138
+ this.halted = false;
1139
+ try {
1140
+ await step();
1141
+ } catch (error) {
1142
+ if (this.closed) return;
1143
+ if (this.halted) this.stopped();
1144
+ else this.fail(error);
1145
+ }
1146
+ }
1147
+ /** Stop ended the agent before a turn could: nothing is running now, and
1148
+ * the next question starts the agent again. */
1149
+ stopped() {
1150
+ this.halted = false;
1151
+ if (this.asking) this.push({
1152
+ kind: "notice",
1153
+ id: randomUUID(),
1154
+ severity: "info",
1155
+ title: "Stopped here."
1156
+ });
1157
+ this.asking = void 0;
1158
+ this.emit({
1159
+ type: "set",
1160
+ status: "idle",
1161
+ error: null
1162
+ });
1163
+ }
1164
+ /** Ends the agent process, failing whatever was waiting on it. */
1165
+ halt() {
1166
+ this.halted = true;
1167
+ this.disconnect();
1168
+ }
1169
+ disconnect() {
1170
+ this.generation += 1;
1171
+ clearTimeout(this.stopTimer);
1172
+ if (this.process) this.stderr = this.process.diagnostics();
1173
+ this.connection?.close();
1174
+ this.process?.stop();
1175
+ this.connection = void 0;
1176
+ this.process = void 0;
1177
+ }
1178
+ /** Starts the agent again if it stopped since the last turn. */
1179
+ async reconnect() {
1180
+ if (this.connection && !this.connection.signal.aborted) return;
1181
+ this.disconnect();
1182
+ this.emit({
1183
+ type: "set",
1184
+ status: "starting"
1185
+ });
1186
+ await this.connect();
1187
+ }
1188
+ /** Starts the agent and opens the session, or gives up after a while. */
1189
+ async connect() {
1190
+ let timer;
1191
+ const expired = new Promise((_resolve, reject) => {
1192
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${this.state.agentName} did not start within ${Math.round(this.limits.startMs / 1e3)} seconds.`)), this.limits.startMs);
1193
+ });
1194
+ const starting = this.startAgent();
1195
+ starting.catch(() => {});
1196
+ try {
1197
+ await Promise.race([starting, expired]);
1198
+ } catch (error) {
1199
+ this.disconnect();
1200
+ throw error;
1201
+ } finally {
1202
+ clearTimeout(timer);
1203
+ }
1204
+ }
1205
+ async startAgent() {
1206
+ const generation = ++this.generation;
1207
+ const launched = await this.launch(this.start.agent, this.start.cwd, {
1208
+ bypass: this.state.bypass,
1209
+ mcpServers: this.mcpServers
1210
+ });
1211
+ if (this.closed || this.halted || generation !== this.generation) {
1212
+ launched.stop();
1213
+ throw new Error(`${this.state.agentName} was stopped while it started.`);
1214
+ }
1215
+ this.process = launched;
1216
+ const connection = launched.connect(client({ name: "whiteboard" }).onRequest(methods.client.session.requestPermission, ({ params, signal }) => this.requestPermission(params, signal)).onNotification(methods.client.session.update, ({ params }) => this.update(params)).onRequest("cursor/update_todos", cursorTodosSchema, ({ params }) => ({ outcome: {
1217
+ outcome: "accepted",
1218
+ todos: params.todos
1219
+ } })));
1220
+ this.connection = connection;
1221
+ const agent = connection.agent;
1222
+ const initialized = await agent.request(methods.agent.initialize, {
1223
+ protocolVersion: PROTOCOL_VERSION,
1224
+ clientCapabilities: {
1225
+ session: { notices: {} },
1226
+ _meta: { parameterizedModelPicker: true }
1227
+ },
1228
+ clientInfo: {
1229
+ name: "whiteboard",
1230
+ title: "Whiteboard",
1231
+ version: "1"
1232
+ }
1233
+ });
1234
+ this.emit({
1235
+ type: "set",
1236
+ accepts: { image: initialized.agentCapabilities?.promptCapabilities?.image === true }
1237
+ });
1238
+ const session = await this.session(connection, generation, initialized.agentCapabilities?.loadSession === true);
1239
+ const { bypass } = this.state;
1240
+ const spec = askAgents[this.start.agent];
1241
+ const mode = bypass ? spec.bypass?.mode : spec.readOnlyMode;
1242
+ const configurable = mode !== void 0 && session.response.configOptions?.some((option) => option.id === "mode" && option.type === "select" && selectOptionsSchema.safeParse(option.options).data?.flatMap((choice) => "group" in choice ? choice.options : [choice]).some((choice) => choice.value === mode));
1243
+ const selectable = mode !== void 0 && session.response.modes?.availableModes.some((available) => available.id === mode);
1244
+ let config = session.response.configOptions;
1245
+ if (configurable) config = (await agent.request(methods.agent.session.setConfigOption, {
1246
+ sessionId: session.sessionId,
1247
+ configId: "mode",
1248
+ value: mode
1249
+ })).configOptions ?? config;
1250
+ else if (selectable) await agent.request(methods.agent.session.setMode, {
1251
+ sessionId: session.sessionId,
1252
+ modeId: mode
1253
+ });
1254
+ this.emit({
1255
+ type: "set",
1256
+ readOnly: !bypass && Boolean(configurable || selectable)
1257
+ });
1258
+ this.useConfig(config);
1259
+ for (const kind of askChoiceKinds) {
1260
+ const picked = this.picks[kind];
1261
+ const select = this.state.choices?.[kind];
1262
+ if (picked && picked !== select?.current && select?.options.some((option) => option.value === picked)) await this.change(kind, picked);
1263
+ }
1264
+ }
1265
+ /** Answers the next question with another model or effort, starting the
1266
+ * agent again if it has stopped since. */
1267
+ async choose(kind, value) {
1268
+ if (this.state.status !== "idle") throw new Error("Settings can change between answers.");
1269
+ if (!this.connection || this.connection.signal.aborted) {
1270
+ await this.attempt(async () => {
1271
+ await this.reconnect();
1272
+ this.emit({
1273
+ type: "set",
1274
+ status: "idle"
1275
+ });
1276
+ });
1277
+ if (this.state.status !== "idle") return;
1278
+ }
1279
+ await this.change(kind, value);
1280
+ }
1281
+ async change(kind, value) {
1282
+ const configId = this.configIds.get(kind);
1283
+ if (!this.connection || !this.sessionId || !configId) throw new Error(`${this.state.agentName} offers no such choice.`);
1284
+ const response = await this.connection.agent.request(methods.agent.session.setConfigOption, {
1285
+ sessionId: this.sessionId,
1286
+ configId,
1287
+ value
1288
+ });
1289
+ this.picks[kind] = value;
1290
+ this.useConfig(response.configOptions);
1291
+ }
1292
+ useConfig(options) {
1293
+ const found = choicesOf(options);
1294
+ this.noteSettings(options, found);
1295
+ if (!found.size) return;
1296
+ const choices = {};
1297
+ for (const [kind, { configId, select }] of found) {
1298
+ this.configIds.set(kind, configId);
1299
+ choices[kind] = select;
1300
+ }
1301
+ this.emit({
1302
+ type: "set",
1303
+ choices
1304
+ });
1305
+ this.announce();
1306
+ }
1307
+ /** Says when a setting the panel does not show changes, which can be
1308
+ * all a command does. */
1309
+ noteSettings(options, choices) {
1310
+ const shown = new Set([...choices.values()].map(({ configId }) => configId));
1311
+ for (const option of options ?? []) {
1312
+ if (option.type !== "select" || shown.has(option.id)) continue;
1313
+ const before = this.settings.get(option.id);
1314
+ this.settings.set(option.id, option.currentValue);
1315
+ if (before === void 0 || before === option.currentValue) continue;
1316
+ const value = selectOptionsSchema.safeParse(option.options).data?.flatMap((choice) => "group" in choice ? choice.options : [choice]).find((choice) => choice.value === option.currentValue)?.name ?? option.currentValue;
1317
+ this.push({
1318
+ kind: "notice",
1319
+ id: randomUUID(),
1320
+ severity: "info",
1321
+ title: `${this.state.agentName} set ${option.name.toLowerCase()} to ${value}.`
1322
+ });
1323
+ }
1324
+ }
1325
+ /** Tells the host what the agent offers now, once its settings are
1326
+ * known: a reopening agent lists its commands first, and what it says
1327
+ * then must not stand as an offer of no model. */
1328
+ announce() {
1329
+ const { choices, commands, accepts } = this.state;
1330
+ if (!choices) return;
1331
+ const offer = { choices };
1332
+ if (commands) offer.commands = commands;
1333
+ if (accepts) offer.accepts = accepts;
1334
+ this.start.onOffer?.(offer);
1335
+ }
1336
+ /** Edits and runs commands without asking from the next answer, or stops:
1337
+ * the agent starts again in that mode and reopens the conversation. */
1338
+ async permit(bypass) {
1339
+ if (this.state.status !== "idle") throw new Error("Settings can change between answers.");
1340
+ if (bypass === this.state.bypass) return;
1341
+ if (bypass && !askAgents[this.start.agent].bypass) throw new Error(`${this.state.agentName} cannot bypass permissions.`);
1342
+ this.emit({
1343
+ type: "set",
1344
+ bypass
1345
+ });
1346
+ this.permitted = true;
1347
+ this.start.onBypass?.(bypass);
1348
+ this.disconnect();
1349
+ this.settings.clear();
1350
+ this.emit({
1351
+ type: "set",
1352
+ status: "starting"
1353
+ });
1354
+ await this.attempt(async () => {
1355
+ await this.connect();
1356
+ this.emit({
1357
+ type: "set",
1358
+ status: "idle"
1359
+ });
1360
+ });
1361
+ }
1362
+ /** What the session starts with: read-only, or bypassing permissions. */
1363
+ sessionMeta() {
1364
+ const spec = askAgents[this.start.agent];
1365
+ return this.state.bypass && spec.bypass?.sessionMeta || spec.sessionMeta;
1366
+ }
1367
+ /** The agent says which mode it is in now; only its read-only one keeps
1368
+ * the checkout as it is. */
1369
+ useMode(mode) {
1370
+ const readOnly = !this.state.bypass && mode === askAgents[this.start.agent].readOnlyMode;
1371
+ if (readOnly !== this.state.readOnly) this.emit({
1372
+ type: "set",
1373
+ readOnly
1374
+ });
1375
+ }
1376
+ /** A new session, or the earlier one loaded with its history replayed. */
1377
+ async session(connection, generation, canLoad) {
1378
+ const agent = connection.agent;
1379
+ const start = this.start;
1380
+ const name = this.state.agentName;
1381
+ const earlier = this.sessionId;
1382
+ if (!earlier) {
1383
+ const response = await agent.request(methods.agent.session.new, {
1384
+ cwd: start.cwd,
1385
+ mcpServers: this.mcpServers,
1386
+ _meta: this.sessionMeta()
1387
+ });
1388
+ this.sessionId = response.sessionId;
1389
+ start.onSession?.(response.sessionId);
1390
+ this.greeting = piGreetingSchema.safeParse(response._meta).data?.piAcp.startupInfo;
1391
+ return {
1392
+ sessionId: response.sessionId,
1393
+ response
1394
+ };
1395
+ }
1396
+ const unloadable = (reason, cause) => {
1397
+ this.unloadable = true;
1398
+ return new Error(`${name} could not reopen this conversation. ${reason} Try again to continue it in a new session, which starts without what was said before.`, { cause });
1399
+ };
1400
+ if (!canLoad) throw unloadable(`${name} cannot reopen past conversations.`);
1401
+ this.shown ||= this.state.entries.length > 0;
1402
+ this.replaying = true;
1403
+ try {
1404
+ return {
1405
+ sessionId: earlier,
1406
+ response: await agent.request(methods.agent.session.load, {
1407
+ sessionId: earlier,
1408
+ cwd: start.cwd,
1409
+ mcpServers: this.mcpServers,
1410
+ _meta: this.sessionMeta()
1411
+ })
1412
+ };
1413
+ } catch (error) {
1414
+ if (signedOut(error) || this.closed || this.halted || generation !== this.generation) throw error;
1415
+ throw unloadable(errorMessage(error), error);
1416
+ } finally {
1417
+ this.replaying = false;
1418
+ }
1419
+ }
1420
+ /** Asks a question in the session, with the selection first if the
1421
+ * session has not had it. */
1422
+ async prompt(question) {
1423
+ const { connection, sessionId } = this;
1424
+ if (!connection || !sessionId) throw new Error(`${this.state.agentName} is not running.`);
1425
+ const command = question.text.startsWith("/");
1426
+ const withSelection = this.needsContext && !command;
1427
+ const withPermission = (withSelection || this.permitted) && !command;
1428
+ const context = [...withSelection ? [this.start.context] : [], ...withPermission ? [this.state.bypass ? "The reviewer lets you change files in the checkout and run commands without asking." : "Do not change files in the checkout."] : []].join("\n");
1429
+ this.emit({
1430
+ type: "set",
1431
+ status: "running",
1432
+ error: null
1433
+ });
1434
+ const prompt = [...context ? withContext(context, question.text) : [{
1435
+ type: "text",
1436
+ text: question.text
1437
+ }], ...this.attachments(question)];
1438
+ let stopReason;
1439
+ try {
1440
+ ({stopReason} = await connection.agent.request(methods.agent.session.prompt, {
1441
+ sessionId,
1442
+ prompt
1443
+ }));
1444
+ } catch (error) {
1445
+ if (!this.halted) throw error;
1446
+ stopReason = "cancelled";
1447
+ this.halted = false;
1448
+ } finally {
1449
+ clearTimeout(this.stopTimer);
1450
+ }
1451
+ if (withSelection) this.needsContext = false;
1452
+ if (withPermission) this.permitted = false;
1453
+ this.asking = void 0;
1454
+ const asked = this.state.entries.findLastIndex((entry) => entry.kind === "user");
1455
+ if (stopReason === "cancelled") this.push({
1456
+ kind: "notice",
1457
+ id: randomUUID(),
1458
+ severity: "info",
1459
+ title: "Stopped here."
1460
+ });
1461
+ else if (stopReason === "end_turn" && asked === this.state.entries.length - 1) this.push({
1462
+ kind: "notice",
1463
+ id: randomUUID(),
1464
+ severity: "info",
1465
+ title: `${this.state.agentName} finished without replying.`
1466
+ });
1467
+ this.emit({
1468
+ type: "set",
1469
+ status: "idle",
1470
+ error: stopReason === "refusal" ? `${this.state.agentName} declined to answer.` : stopReason === "max_tokens" || stopReason === "max_turn_requests" ? `${this.state.agentName} stopped before finishing.` : null
1471
+ });
1472
+ this.start.onTurn?.();
1473
+ this.start.onSave?.(this.state.entries);
1474
+ }
1475
+ async requestPermission(request, signal) {
1476
+ const id = request.toolCall.toolCallId;
1477
+ const toolKind = request.toolCall.kind ?? "other";
1478
+ const server = mcpServerOf(request.toolCall) ?? this.mcpCalls.get(id);
1479
+ const allowOnce = request.options.find((option) => option.kind === "allow_once");
1480
+ if (allowOnce && (this.state.bypass || server && (READ_ONLY_MCP_SERVERS.has(server) || this.mcpServers.some((provided) => provided.name === server)))) return { outcome: {
1481
+ outcome: "selected",
1482
+ optionId: allowOnce.optionId
1483
+ } };
1484
+ const input = toolDetails(request.toolCall).input ?? this.state.entries.find((entry) => entry.kind === "tool" && entry.id === id)?.input;
1485
+ const entry = {
1486
+ kind: "permission",
1487
+ id,
1488
+ title: request.toolCall.title ?? "Run a tool",
1489
+ toolKind,
1490
+ options: request.options
1491
+ };
1492
+ if (input) entry.input = input;
1493
+ if (REFUSED_KINDS.has(toolKind)) {
1494
+ const reject = request.options.find((option) => option.kind === "reject_once");
1495
+ this.push({
1496
+ ...entry,
1497
+ outcome: reject?.optionId ?? "cancelled",
1498
+ automatic: true
1499
+ });
1500
+ return reject ? { outcome: {
1501
+ outcome: "selected",
1502
+ optionId: reject.optionId
1503
+ } } : { outcome: { outcome: "cancelled" } };
1504
+ }
1505
+ this.push(entry);
1506
+ this.emit({
1507
+ type: "set",
1508
+ status: "waiting"
1509
+ });
1510
+ const response = await new Promise((resolve) => {
1511
+ this.decisions.set(id, resolve);
1512
+ const withdrawn = () => resolve({ outcome: { outcome: "cancelled" } });
1513
+ if (signal.aborted) withdrawn();
1514
+ else signal.addEventListener("abort", withdrawn, { once: true });
1515
+ });
1516
+ this.decisions.delete(id);
1517
+ this.replace(id, "permission", (current) => ({
1518
+ ...current,
1519
+ outcome: response.outcome.outcome === "selected" ? response.outcome.optionId : "cancelled"
1520
+ }));
1521
+ if (this.state.status === "waiting" && !this.decisions.size) this.emit({
1522
+ type: "set",
1523
+ status: "running"
1524
+ });
1525
+ return response;
1526
+ }
1527
+ update({ sessionId, update }) {
1528
+ if (sessionId !== this.sessionId) return;
1529
+ this.apply(update);
1530
+ }
1531
+ apply(update) {
1532
+ if (this.useSessionState(update)) return;
1533
+ if (this.replaying && this.shown) return;
1534
+ switch (update.sessionUpdate) {
1535
+ case "user_message_chunk":
1536
+ if (this.replaying && update.content.type === "text") this.replayUser(update.content.text, update.messageId ?? void 0);
1537
+ return;
1538
+ case "agent_message_chunk": {
1539
+ if (update.content.type !== "text") return;
1540
+ const { text } = update.content;
1541
+ if (text === this.greeting) {
1542
+ this.greeting = void 0;
1543
+ return;
1544
+ }
1545
+ const last = this.state.entries.at(-1);
1546
+ const id = update.messageId ?? void 0;
1547
+ if (last?.kind === "agent" && (!id || last.id === id)) {
1548
+ this.emit({
1549
+ type: "append",
1550
+ id: last.id,
1551
+ text
1552
+ });
1553
+ return;
1554
+ }
1555
+ this.push({
1556
+ kind: "agent",
1557
+ id: id ?? randomUUID(),
1558
+ text
1559
+ });
1560
+ return;
1561
+ }
1562
+ case "config_option_update": {
1563
+ this.useConfig(update.configOptions);
1564
+ const mode = update.configOptions.find((option) => option.id === "mode" && option.type === "select");
1565
+ if (mode?.type === "select") this.useMode(mode.currentValue);
1566
+ return;
1567
+ }
1568
+ case "current_mode_update":
1569
+ this.useMode(update.currentModeId);
1570
+ return;
1571
+ case "notice": {
1572
+ if (this.state.entries.some((entry) => entry.kind === "notice" && entry.title === update.title && entry.description === (update.description ?? void 0))) return;
1573
+ const notice = {
1574
+ kind: "notice",
1575
+ id: randomUUID(),
1576
+ severity: update.severity,
1577
+ title: update.title
1578
+ };
1579
+ if (update.description) notice.description = update.description;
1580
+ this.push(notice);
1581
+ return;
1582
+ }
1583
+ case "tool_call":
1584
+ this.noteMcpServer(update);
1585
+ this.push({
1586
+ kind: "tool",
1587
+ id: update.toolCallId,
1588
+ title: update.title,
1589
+ toolKind: update.kind ?? "other",
1590
+ status: update.status ?? "pending",
1591
+ ...toolDetails(update)
1592
+ });
1593
+ this.showPlan(update);
1594
+ return;
1595
+ case "tool_call_update":
1596
+ this.noteMcpServer(update);
1597
+ if (!this.state.entries.some((entry) => entry.kind === "tool" && entry.id === update.toolCallId)) {
1598
+ this.push({
1599
+ kind: "tool",
1600
+ id: update.toolCallId,
1601
+ title: update.title ?? "Tool call",
1602
+ toolKind: update.kind ?? "other",
1603
+ status: update.status ?? "pending",
1604
+ ...toolDetails(update)
1605
+ });
1606
+ this.showPlan(update);
1607
+ return;
1608
+ }
1609
+ this.replace(update.toolCallId, "tool", (current) => {
1610
+ const status = update.status ?? current.status;
1611
+ const output = status === "completed" || status === "failed" ? toolOutput(update) : void 0;
1612
+ const next = {
1613
+ ...current,
1614
+ title: update.title ?? current.title,
1615
+ toolKind: update.kind ?? current.toolKind,
1616
+ status,
1617
+ ...toolDetails(update)
1618
+ };
1619
+ if (output) next.output = output;
1620
+ return next;
1621
+ });
1622
+ this.showPlan(update);
1623
+ return;
1624
+ }
1625
+ }
1626
+ /** What the session says about itself rather than the turn: its
1627
+ * commands, how full it is, and its name. A reload's replay keeps them. */
1628
+ useSessionState(update) {
1629
+ switch (update.sessionUpdate) {
1630
+ case "available_commands_update":
1631
+ this.emit({
1632
+ type: "set",
1633
+ commands: update.availableCommands.map(commandOf)
1634
+ });
1635
+ this.announce();
1636
+ return true;
1637
+ case "usage_update": {
1638
+ const { used, size, cost } = update;
1639
+ this.emit({
1640
+ type: "set",
1641
+ usage: cost ? {
1642
+ used,
1643
+ size,
1644
+ cost: {
1645
+ amount: cost.amount,
1646
+ currency: cost.currency
1647
+ }
1648
+ } : {
1649
+ used,
1650
+ size
1651
+ }
1652
+ });
1653
+ return true;
1654
+ }
1655
+ case "session_info_update": {
1656
+ const title = update.title?.trim();
1657
+ if (title && title !== this.state.title) {
1658
+ this.emit({
1659
+ type: "set",
1660
+ title
1661
+ });
1662
+ this.start.onTitle?.(title);
1663
+ }
1664
+ return true;
1665
+ }
1666
+ default: return false;
1667
+ }
1668
+ }
1669
+ noteMcpServer(update) {
1670
+ const server = mcpServerOf(update);
1671
+ if (server) this.mcpCalls.set(update.toolCallId, server);
1672
+ }
1673
+ /**
1674
+ * In its read-only mode Claude answers by writing a plan and asking to
1675
+ * leave the mode with it. That request is refused, so the plan is shown
1676
+ * as the answer; the refusal's own text, once it comes, is not.
1677
+ */
1678
+ showPlan(update) {
1679
+ const call = this.state.entries.find((entry) => entry.kind === "tool" && entry.id === update.toolCallId);
1680
+ if (call?.kind !== "tool" || call.toolKind !== "switch_mode" || call.status === "completed" || call.status === "failed") return;
1681
+ const plan = (update.content ?? []).flatMap((item) => item.type === "content" && item.content.type === "text" ? [item.content.text] : []).join("\n\n").trim();
1682
+ if (!plan) return;
1683
+ const id = `plan:${update.toolCallId}`;
1684
+ if (this.state.entries.some((entry) => entry.id === id)) this.replace(id, "agent", (current) => ({
1685
+ ...current,
1686
+ text: plan
1687
+ }));
1688
+ else this.push({
1689
+ kind: "agent",
1690
+ id,
1691
+ text: plan
1692
+ });
1693
+ }
1694
+ /** Chunks of one replayed message join, like an answer's. */
1695
+ replayUser(text, messageId) {
1696
+ const last = this.state.entries.at(-1);
1697
+ if (last?.kind === "user" && this.replayedUser.has(last.id) && (!messageId || last.id === messageId)) {
1698
+ const sent = this.replayedUser.get(last.id) + text;
1699
+ this.replayedUser.set(last.id, sent);
1700
+ this.emit({
1701
+ type: "entry",
1702
+ entry: {
1703
+ ...last,
1704
+ text: withoutContext(sent)
1705
+ }
1706
+ });
1707
+ return;
1708
+ }
1709
+ const id = messageId ?? randomUUID();
1710
+ this.replayedUser.set(id, text);
1711
+ this.push({
1712
+ kind: "user",
1713
+ id,
1714
+ text: withoutContext(text)
1715
+ });
1716
+ }
1717
+ addUser(question) {
1718
+ const id = randomUUID();
1719
+ const attachments = attachmentsOf(question);
1720
+ const entry = {
1721
+ kind: "user",
1722
+ id,
1723
+ text: question.text,
1724
+ at: Date.now()
1725
+ };
1726
+ if (attachments.length) entry.attachments = attachments;
1727
+ this.push(entry);
1728
+ return id;
1729
+ }
1730
+ /** A question's files, as links into the checkout, and its images, for
1731
+ * an agent that reads them. */
1732
+ attachments(question) {
1733
+ const root = path.resolve(this.start.cwd);
1734
+ const blocks = [];
1735
+ for (const mention of question.mentions ?? []) {
1736
+ const file = path.resolve(root, mention);
1737
+ if (!file.startsWith(root + path.sep)) continue;
1738
+ blocks.push({
1739
+ type: "resource_link",
1740
+ uri: pathToFileURL(file).href,
1741
+ name: path.basename(file),
1742
+ title: mention
1743
+ });
1744
+ }
1745
+ const images = question.images ?? [];
1746
+ if (!images.length) return blocks;
1747
+ if (!this.state.accepts?.image) {
1748
+ this.push({
1749
+ kind: "notice",
1750
+ id: randomUUID(),
1751
+ severity: "warning",
1752
+ title: `${this.state.agentName} does not read images, so they were left out.`
1753
+ });
1754
+ return blocks;
1755
+ }
1756
+ for (const { mimeType, data } of images) blocks.push({
1757
+ type: "image",
1758
+ mimeType,
1759
+ data
1760
+ });
1761
+ return blocks;
1762
+ }
1763
+ fail(cause) {
1764
+ if (this.closed) return;
1765
+ if (signedOut(cause)) {
1766
+ const { name, signIn } = askAgents[this.start.agent];
1767
+ this.emit({
1768
+ type: "set",
1769
+ status: "failed",
1770
+ error: `${name} is signed out.`,
1771
+ signIn
1772
+ });
1773
+ return;
1774
+ }
1775
+ const diagnostics = (this.process?.diagnostics() ?? this.stderr).trim();
1776
+ this.emit({
1777
+ type: "set",
1778
+ status: "failed",
1779
+ error: [errorMessage(cause), diagnostics?.split("\n").at(-1)].filter(Boolean).join("\n")
1780
+ });
1781
+ }
1782
+ push(entry) {
1783
+ this.emit({
1784
+ type: "add",
1785
+ entry
1786
+ });
1787
+ }
1788
+ replace(id, kind, change) {
1789
+ const current = this.state.entries.find((entry) => entry.id === id && entry.kind === kind);
1790
+ if (current) this.emit({
1791
+ type: "entry",
1792
+ entry: change(current)
1793
+ });
1794
+ }
1795
+ /** The one way the state changes, so watchers can follow it change by change. */
1796
+ emit(change) {
1797
+ if (this.closed) return;
1798
+ this.state = applyAskChange(this.state, change);
1799
+ this.seq += 1;
1800
+ const update = {
1801
+ seq: this.seq,
1802
+ change
1803
+ };
1804
+ for (const listener of this.listeners) listener(update);
1805
+ }
1806
+ };
1807
+ //#endregion
1808
+ //#region src/ask/threads.ts
1809
+ /** How long an agent may take to say what it offers. */
1810
+ const OFFER_TIMEOUT_MS = 3e4;
1811
+ /** How long after its session opens an agent may take to list its
1812
+ * commands, which it sends on its own rather than in the response. */
1813
+ const COMMANDS_WAIT_MS = 2e3;
1814
+ /** What an agent offers, from a session it starts and leaves without
1815
+ * asking anything: with the model given, where it offers that model, since
1816
+ * the efforts on offer depend on it. */
1817
+ async function offeredBy(launch, agent, cwd, model) {
1818
+ const process = await launch(agent, cwd);
1819
+ const timer = setTimeout(() => process.stop(), OFFER_TIMEOUT_MS);
1820
+ let listed = () => {};
1821
+ const commands = new Promise((resolve) => {
1822
+ listed = resolve;
1823
+ });
1824
+ try {
1825
+ const connection = process.connect(client({ name: "whiteboard" }).onNotification(methods.client.session.update, ({ params: { update } }) => {
1826
+ if (update.sessionUpdate === "available_commands_update") listed(update.availableCommands.map(commandOf));
1827
+ }));
1828
+ const initialized = await connection.agent.request(methods.agent.initialize, {
1829
+ protocolVersion: PROTOCOL_VERSION,
1830
+ clientCapabilities: { _meta: { parameterizedModelPicker: true } },
1831
+ clientInfo: {
1832
+ name: "whiteboard",
1833
+ title: "Whiteboard",
1834
+ version: "1"
1835
+ }
1836
+ });
1837
+ const session = await connection.agent.request(methods.agent.session.new, {
1838
+ cwd,
1839
+ mcpServers: [],
1840
+ _meta: askAgents[agent].sessionMeta
1841
+ });
1842
+ const offer = {
1843
+ choices: {},
1844
+ accepts: { image: initialized.agentCapabilities?.promptCapabilities?.image === true }
1845
+ };
1846
+ let found = choicesOf(session.configOptions);
1847
+ const models = found.get("model");
1848
+ if (model && models && model !== models.select.current && models.select.options.some((option) => option.value === model)) found = choicesOf((await connection.agent.request(methods.agent.session.setConfigOption, {
1849
+ sessionId: session.sessionId,
1850
+ configId: models.configId,
1851
+ value: model
1852
+ })).configOptions);
1853
+ for (const [kind, { select }] of found) offer.choices[kind] = select;
1854
+ let wait;
1855
+ const offered = await Promise.race([commands, new Promise((resolve) => {
1856
+ wait = setTimeout(() => resolve(void 0), COMMANDS_WAIT_MS);
1857
+ })]).finally(() => clearTimeout(wait));
1858
+ if (offered) offer.commands = offered;
1859
+ return offer;
1860
+ } catch (error) {
1861
+ const diagnostics = process.diagnostics().trim();
1862
+ throw new Error(`${askAgents[agent].name} did not say what it offers. ${errorMessage(error)}${diagnostics ? `\n${diagnostics}` : ""}`, { cause: error });
1863
+ } finally {
1864
+ clearTimeout(timer);
1865
+ process.stop();
1866
+ }
1867
+ }
1868
+ /** The live Ask threads of one server; they end with it. */
1869
+ var AskThreads = class {
1870
+ launch;
1871
+ tools;
1872
+ limits;
1873
+ threads = /* @__PURE__ */ new Map();
1874
+ /** One question to each agent at a time about what it offers with a
1875
+ * model. */
1876
+ offers = /* @__PURE__ */ new Map();
1877
+ mcpServers;
1878
+ constructor(launch, tools = {}, limits = askThreadLimits) {
1879
+ this.launch = launch;
1880
+ this.tools = tools;
1881
+ this.limits = limits;
1882
+ this.mcpServers = tools.mcpServers ?? (() => []);
1883
+ }
1884
+ /** How the agent's sessions reach Whiteboard's tools: the MCP tools they
1885
+ * are given where its model gets them, else the CLI from its shell. */
1886
+ async reach(agent) {
1887
+ if (this.mcpServers().length && await (this.tools.takesMcp ?? askAgentTakesMcp)(agent)) return { kind: "mcp" };
1888
+ const command = this.tools.cli?.();
1889
+ return command ? {
1890
+ kind: "cli",
1891
+ command
1892
+ } : void 0;
1893
+ }
1894
+ open(start) {
1895
+ const thread = new AskThread(this.launch, start, this.mcpServers, this.limits);
1896
+ this.threads.set(thread.id, thread);
1897
+ thread.open();
1898
+ return thread;
1899
+ }
1900
+ get(id) {
1901
+ return this.threads.get(id);
1902
+ }
1903
+ /** What the agent offers to choose before anything is asked of it, with
1904
+ * its own model or the one given. */
1905
+ offered(agent, cwd, model) {
1906
+ const key = JSON.stringify([agent, model]);
1907
+ let offer = this.offers.get(key);
1908
+ if (!offer) {
1909
+ offer = offeredBy(this.launch, agent, cwd, model).finally(() => this.offers.delete(key));
1910
+ this.offers.set(key, offer);
1911
+ }
1912
+ return offer;
1913
+ }
1914
+ close(id) {
1915
+ this.threads.get(id)?.close();
1916
+ this.threads.delete(id);
1917
+ }
1918
+ closeAll() {
1919
+ for (const id of [...this.threads.keys()]) this.close(id);
1920
+ }
1921
+ };
1922
+ //#endregion
1923
+ //#region src/ask/checkout-files.ts
1924
+ const execFileAsync$1 = promisify(execFile);
1925
+ /** The checkout's files relative to its root: tracked ones, and new ones git
1926
+ * does not ignore. None where git cannot list them. */
1927
+ async function checkoutFiles(root) {
1928
+ try {
1929
+ const { stdout } = await execFileAsync$1("git", [
1930
+ "-C",
1931
+ root,
1932
+ "ls-files",
1933
+ "-z",
1934
+ "--cached",
1935
+ "--others",
1936
+ "--exclude-standard"
1937
+ ], { maxBuffer: 67108864 });
1938
+ return [...new Set(stdout.split("\0").filter(Boolean))];
1939
+ } catch {
1940
+ return [];
1941
+ }
1942
+ }
1943
+ /** How long a listing serves mentions before git is asked again. */
1944
+ const LISTING_MS = 1e4;
1945
+ const listings = /* @__PURE__ */ new Map();
1946
+ /** The checkout's files for a mention picker, which asks on each keystroke:
1947
+ * listed again only once the last listing is a few seconds old. */
1948
+ function mentionableFiles(root) {
1949
+ const now = Date.now();
1950
+ const listed = listings.get(root);
1951
+ if (listed && now - listed.at < LISTING_MS) return listed.files;
1952
+ const files = checkoutFiles(root);
1953
+ listings.set(root, {
1954
+ at: now,
1955
+ files
1956
+ });
1957
+ return files;
1958
+ }
1959
+ //#endregion
1960
+ //#region src/ask/file-refs.ts
1961
+ /** `:42`, `:42:7`, `:42-50`, `#L42`, `#L42C7`, `#L42-L50`. */
1962
+ const lineSuffix = /(?::(\d+)(?::\d+)?(?:-\d+)?|#L(\d+)(?:C\d+)?(?:-L?\d+)?)$/;
1963
+ /** A scheme, as in `https://`; a Windows drive letter is not one. */
1964
+ const urlScheme = /^[a-z][a-z\d+.-]+:/i;
1965
+ /** What a piece of inline code or a link target names, if it could be a
1966
+ * file: a path with a directory or an extension, and no spaces. Whether the
1967
+ * file exists is the checkout's to say. */
1968
+ function parseFileRef(text) {
1969
+ let value = text.trim();
1970
+ if (value.startsWith("file://")) try {
1971
+ value = decodeURIComponent(value.slice(7));
1972
+ } catch {
1973
+ return;
1974
+ }
1975
+ if (!value || value.length > 400 || /[\s`'"<>|*?{}$]/.test(value) || urlScheme.test(value) || value.startsWith("-")) return void 0;
1976
+ const match = lineSuffix.exec(value);
1977
+ const path = (match ? value.slice(0, match.index) : value).replace(/^\.\//, "");
1978
+ const name = path.split("/").at(-1) ?? "";
1979
+ if (!name || !(path.includes("/") || /\.[a-z][a-z\d]*$/i.test(name))) return void 0;
1980
+ const line = Number(match?.[1] ?? match?.[2]);
1981
+ return line > 0 ? {
1982
+ path,
1983
+ line
1984
+ } : { path };
1985
+ }
1986
+ /** Resolves the paths an answer names to files in the checkout, relative to
1987
+ * its root. Agents write paths from the checkout root, as absolute paths, or
1988
+ * relative to the package they are in; a path that could be several files
1989
+ * resolves to the one the agent's tools touched, else to none. */
1990
+ function resolveFileRefs(root, files, paths, touched = []) {
1991
+ const known = new Set(files);
1992
+ const inRoot = (path) => path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
1993
+ const named = (path) => known.has(path) ? [path] : files.filter((file) => file.endsWith(`/${path}`));
1994
+ const touchedFiles = new Set(touched.flatMap((path) => {
1995
+ const relative = inRoot(path);
1996
+ return relative.startsWith("/") ? [] : named(relative);
1997
+ }));
1998
+ const resolved = /* @__PURE__ */ new Map();
1999
+ for (const asked of paths) {
2000
+ const path = inRoot(asked);
2001
+ if (path.startsWith("/")) continue;
2002
+ for (const candidate of [path, path.replace(/^[ab]\//, "")]) {
2003
+ const matches = named(candidate);
2004
+ const touchedMatches = matches.filter((file) => touchedFiles.has(file));
2005
+ const match = matches.length === 1 ? matches[0] : touchedMatches.length === 1 ? touchedMatches[0] : void 0;
2006
+ if (match) {
2007
+ resolved.set(asked, match);
2008
+ break;
2009
+ }
2010
+ }
2011
+ }
2012
+ return resolved;
2013
+ }
2014
+ /**
2015
+ * NDJSON of a thread: a snapshot, then each change in `seq` order. Answer
2016
+ * tokens arrive as appends, so a reply costs about what the agent sent.
2017
+ */
2018
+ function watchAskThread(thread, backlog = 256) {
2019
+ const encoder = new TextEncoder();
2020
+ let pending = [thread.snapshot()];
2021
+ let stop = () => {};
2022
+ /** The thread closed; the stream ends once `pending` is sent. */
2023
+ let ended = false;
2024
+ /** The stream ended, so nothing more may be enqueued. */
2025
+ let finished = false;
2026
+ const flush = (controller) => {
2027
+ while (pending.length && (controller.desiredSize ?? 0) > 0) controller.enqueue(encoder.encode(`${JSON.stringify(pending.shift())}\n`));
2028
+ if (ended && !pending.length && !finished) {
2029
+ finished = true;
2030
+ controller.close();
2031
+ }
2032
+ };
2033
+ const body = new ReadableStream({
2034
+ start(controller) {
2035
+ const unsubscribe = thread.subscribe((update) => {
2036
+ pending.push(update);
2037
+ if (pending.length > backlog) pending = [thread.snapshot()];
2038
+ flush(controller);
2039
+ });
2040
+ const unclose = thread.onClose(() => {
2041
+ ended = true;
2042
+ unsubscribe();
2043
+ flush(controller);
2044
+ });
2045
+ stop = () => {
2046
+ unsubscribe();
2047
+ unclose();
2048
+ };
2049
+ flush(controller);
2050
+ },
2051
+ pull: flush,
2052
+ cancel() {
2053
+ stop();
2054
+ }
2055
+ });
2056
+ return new Response(body, { headers: {
2057
+ "content-type": "application/x-ndjson",
2058
+ "cache-control": "no-store"
2059
+ } });
2060
+ }
2061
+ //#endregion
2062
+ //#region src/review-stack.ts
2063
+ const execFileAsync = promisify(execFile);
2064
+ const GitHubStacksSchema = z.array(z.object({ pull_requests: z.array(z.object({
2065
+ number: z.number().int().positive(),
2066
+ head: z.object({ ref: z.string().min(1) })
2067
+ })) }));
2068
+ async function resolveReviewStackLayers(subject, reviews, runGitHubApi = defaultRunGitHubApi) {
2069
+ const binding = subject.origin?.pullRequestUrl?.match(/^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/);
2070
+ if (!binding) return [];
2071
+ const [, host, owner, repository, number] = binding;
2072
+ const pullRequestNumber = Number(number);
2073
+ const repoKey = `https://${host}/${owner}/${repository}`;
2074
+ let stacks;
2075
+ try {
2076
+ stacks = GitHubStacksSchema.parse(JSON.parse(await runGitHubApi(host, `repos/${owner}/${repository}/stacks?pull_request=${number}`)));
2077
+ } catch {
2078
+ return [];
2079
+ }
2080
+ const stack = stacks.find((candidate) => candidate.pull_requests.some((pr) => pr.number === pullRequestNumber));
2081
+ if (!stack) return [];
2082
+ const currentIndex = stack.pull_requests.findIndex((pr) => pr.number === pullRequestNumber);
2083
+ return stack.pull_requests.map((pr, index) => {
2084
+ const review = reviews.find((candidate) => {
2085
+ return (candidate.origin?.pullRequestUrl?.replace(/\/pull\/\d+.*$/, "") ?? candidate.pins?.repositoryId) === repoKey && candidate.origin?.pullRequestNumber === pr.number;
2086
+ });
2087
+ return {
2088
+ branch: pr.head.ref,
2089
+ pullRequestNumber: pr.number,
2090
+ pullRequestUrl: `${repoKey}/pull/${pr.number}`,
2091
+ reviewUuid: review?.reviewId ?? null,
2092
+ reviewTitle: review?.title ?? null,
2093
+ relation: index < currentIndex ? "earlier" : index === currentIndex ? "current" : "later"
2094
+ };
2095
+ });
2096
+ }
2097
+ async function defaultRunGitHubApi(host, endpoint) {
2098
+ const { stdout } = await execFileAsync("gh", [
2099
+ "api",
2100
+ "--hostname",
2101
+ host,
2102
+ endpoint
2103
+ ], {
2104
+ env: {
2105
+ ...process.env,
2106
+ GH_PROMPT_DISABLED: "1"
2107
+ },
2108
+ encoding: "utf8",
2109
+ timeout: 1e4,
2110
+ maxBuffer: 2097152
2111
+ });
2112
+ return stdout;
2113
+ }
2114
+ //#endregion
2115
+ //#region src/sharing/auth.ts
2116
+ /** CI credentials stay in the server environment and are never persisted to a profile. */
2117
+ async function readSharingAuth(env = process.env) {
2118
+ if (env.DEV_REVIEW_SHARE_TOKEN === void 0) {
2119
+ if (env.DEV_REVIEW_SHARE_ORIGIN !== void 0) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN requires DEV_REVIEW_SHARE_TOKEN.");
2120
+ return readStoreAuth(env);
2121
+ }
2122
+ const token = env.DEV_REVIEW_SHARE_TOKEN.trim();
2123
+ if (!token) throw new ReviewInputError("DEV_REVIEW_SHARE_TOKEN is empty.");
2124
+ let url;
2125
+ try {
2126
+ url = new URL(env.DEV_REVIEW_SHARE_ORIGIN ?? DEFAULT_STORE_ORIGIN);
2127
+ } catch {
2128
+ throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
2129
+ }
2130
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new ReviewInputError("DEV_REVIEW_SHARE_ORIGIN must be a bare HTTPS origin.");
2131
+ return {
2132
+ origin: url.origin,
2133
+ token
2134
+ };
2135
+ }
2136
+ //#endregion
2137
+ //#region src/sharing/host.ts
2138
+ const publishSchema = z.strictObject({
2139
+ reviewId: z.string().min(1),
2140
+ version: z.number().int().nonnegative().optional(),
2141
+ requestId: z.uuid().optional()
2142
+ });
2143
+ /** Mounted behind local host authentication. Account credentials never enter the renderer. */
2144
+ function mountSharingHost(app, store, data, shared, options = {}) {
2145
+ const startLogin = options.login ?? runStoreLogin;
2146
+ const openUrl = options.openUrl ?? openUrlInBrowser;
2147
+ let login = { pending: false };
2148
+ app.get("/sharing/account", async (context) => {
2149
+ const account = await readStoreAuth();
2150
+ return context.json({
2151
+ account: account ? {
2152
+ login: account.login,
2153
+ origin: account.origin
2154
+ } : null,
2155
+ ...login
2156
+ });
2157
+ });
2158
+ app.post("/sharing/login", async (context) => {
2159
+ if (!login.pending) {
2160
+ login = { pending: true };
2161
+ options.onLogin?.("started");
2162
+ const discard = new Writable({ write(_chunk, _encoding, done) {
2163
+ done();
2164
+ } });
2165
+ startLogin({
2166
+ traces: false,
2167
+ stdout: discard,
2168
+ stderr: discard,
2169
+ openUrl: async (url) => {
2170
+ login.url = url;
2171
+ await openUrl(url);
2172
+ }
2173
+ }).then((code) => {
2174
+ login = { pending: false };
2175
+ if (code) login.error = "Sign-in did not finish. Try again.";
2176
+ options.onLogin?.(code ? "failed" : "succeeded", code ? "did_not_finish" : void 0);
2177
+ }).catch(() => {
2178
+ login = {
2179
+ pending: false,
2180
+ error: "Sign-in failed. Try again."
2181
+ };
2182
+ options.onLogin?.("failed", "error");
2183
+ });
2184
+ }
2185
+ return context.json(login);
2186
+ });
2187
+ mountSharingPublisher(app, store, data, options);
2188
+ const imports = /* @__PURE__ */ new Map();
2189
+ app.get("/sharing/import/:id", (context) => {
2190
+ const id = context.req.param("id");
2191
+ const status = shared.status(id);
2192
+ const result = {
2193
+ reviewId: id,
2194
+ ...status
2195
+ };
2196
+ if (status.stage === "ready") return context.json({
2197
+ ...result,
2198
+ title: shared.get(id).snapshot.title
2199
+ });
2200
+ return context.json(result);
2201
+ });
2202
+ app.post("/sharing/import", async (context) => {
2203
+ const { url } = z.strictObject({ url: z.string().max(2048) }).parse(await readBoundedRequestJson(context.req.raw));
2204
+ const parsed = parseShareLink(url);
2205
+ const account = await readStoreAuth();
2206
+ if (parsed.origin !== DEFAULT_STORE_ORIGIN && parsed.origin !== account?.origin) throw new ReviewInputError("This share uses an untrusted service. Sign in to that service before opening its links.", 400);
2207
+ const id = sharedReviewId(parsed.origin, parsed.shareId);
2208
+ if (!imports.has(id)) {
2209
+ shared.setStatus(id, "downloading");
2210
+ const job = (async () => {
2211
+ if (shared.has(id)) {
2212
+ await shared.prepare(id);
2213
+ return;
2214
+ }
2215
+ const bundle = await new ShareClient(parsed.origin).download(parsed.shareId, parsed.capability);
2216
+ await shared.import(parsed.origin, parsed.shareId, bundle);
2217
+ })().catch((error) => {
2218
+ shared.setStatus(id, "error", error instanceof ReviewInputError ? error.message : "This share is unavailable, revoked, or needs a newer Whiteboard version.");
2219
+ }).finally(() => imports.delete(id));
2220
+ imports.set(id, job);
2221
+ shared.trackImport(job);
2222
+ }
2223
+ return context.json({
2224
+ reviewId: id,
2225
+ ...shared.status(id)
2226
+ }, 202);
2227
+ });
2228
+ }
2229
+ /** Publishing needs only an authored store, never Desktop or recipient workspaces. */
2230
+ function mountSharingPublisher(app, store, data, options = {}) {
2231
+ const verifyRepository = options.verifyRepository ?? verifyShareRepository;
2232
+ app.post("/sharing/publish", async (context) => {
2233
+ const input = publishSchema.parse(await readBoundedRequestJson(context.req.raw));
2234
+ if (input.reviewId.startsWith("shared-")) throw new ReviewInputError("Only the authoring review can be shared.", 409);
2235
+ const snapshot = store.read(input.reviewId, input.version);
2236
+ if (snapshot.target?.kind === "worktree") throw new ReviewInputError("Pin this review to commits before sharing it.");
2237
+ validateShareSources(snapshot);
2238
+ const account = await readSharingAuth();
2239
+ if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login before sharing.", 409);
2240
+ const root = snapshot.pins ? store.repositoryPath(snapshot.pins.repositoryId) : void 0;
2241
+ const repository = root ? await (options.readRepository ?? readShareRepository)(root) : void 0;
2242
+ const bundle = await exportShare({
2243
+ store,
2244
+ data,
2245
+ reviewId: input.reviewId,
2246
+ version: snapshot.version,
2247
+ repository
2248
+ });
2249
+ const verification = root && snapshot.pins && repository ? verifyRepository(root, snapshot.pins, repository).then(() => ({ ok: true }), (error) => ({
2250
+ ok: false,
2251
+ error
2252
+ })) : Promise.resolve({ ok: true });
2253
+ try {
2254
+ const result = await new ShareClient(account.origin, account.token, options.fetch).create(bundle, input.requestId ?? randomUUID(), async () => {
2255
+ const result = await verification;
2256
+ if (!result.ok) throw result.error;
2257
+ });
2258
+ const url = new URL(result.url);
2259
+ if (process.env.DEV_FAST_REVIEW_APP_URL_PROTOCOL === "dev-fast-review-preview") url.searchParams.set("app", "preview");
2260
+ options.onPublished?.({
2261
+ reviewId: input.reviewId,
2262
+ version: snapshot.version
2263
+ });
2264
+ return context.json({
2265
+ ...result,
2266
+ url: url.href,
2267
+ version: snapshot.version
2268
+ });
2269
+ } catch (error) {
2270
+ if (error instanceof SharePreflightError) return context.json({ error: error.message }, 422);
2271
+ if (error instanceof ShareAuthError) {
2272
+ if (process.env.DEV_REVIEW_SHARE_TOKEN === void 0) await clearStoreAuth();
2273
+ throw new ReviewInputError("Your sign-in has expired. Sign in again to share.", 401);
2274
+ }
2275
+ throw new ReviewInputError("Sharing failed. Check your connection and login, then retry.", 409);
2276
+ }
2277
+ });
2278
+ app.post("/sharing/revoke", async (context) => {
2279
+ const { shareId } = z.strictObject({ shareId: shareIdSchema }).parse(await readBoundedRequestJson(context.req.raw));
2280
+ const account = await readSharingAuth();
2281
+ if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login first.", 409);
2282
+ await new ShareClient(account.origin, account.token).revoke(shareId);
2283
+ options.onRevoked?.({ shareId });
2284
+ return context.json({
2285
+ shareId,
2286
+ revoked: true
2287
+ });
2288
+ });
2289
+ }
2290
+ //#endregion
2291
+ //#region src/sharing/routes.ts
2292
+ /** Snapshot-backed reads used by the same HTTP routes as local reviews. */
2293
+ var SharedReviewData = class {
2294
+ store;
2295
+ constructor(store) {
2296
+ this.store = store;
2297
+ }
2298
+ map(id, resourceId) {
2299
+ const map = this.store.get(id).presentation.maps[resourceId];
2300
+ if (!map) throw new ReviewInputError("Map is not included in this share.", 404);
2301
+ return map;
2302
+ }
2303
+ async resource(id, resourceId) {
2304
+ const resource = this.store.get(id).manifest.resources.find((item) => item.id === resourceId);
2305
+ if (!resource) throw new ReviewInputError("Resource is not included in this share.", 404);
2306
+ return {
2307
+ data: await this.store.readObject(id, resource.object),
2308
+ mimeType: resource.mimeType
2309
+ };
2310
+ }
2311
+ };
2312
+ //#endregion
2313
+ //#region src/review-api/anchor-quotes.ts
2314
+ const QUOTE_LIMIT = 20;
2315
+ const clip = (line) => line.trim() === "" ? "(blank line)" : line.length > 120 ? `${line.slice(0, 119)}…` : line;
2316
+ /**
2317
+ * The first and last line of each anchor an edit added or changed, so its
2318
+ * author can see a range is the one meant without reading the file again.
2319
+ * A range that can't be read was already rejected, so it is skipped here.
2320
+ * `unquoted` counts the anchors past the limit, so a short list isn't
2321
+ * mistaken for a complete one.
2322
+ */
2323
+ async function anchorQuotes(before, after, read) {
2324
+ const key = (id, source) => `${id}\0${selectionKey(source)}`;
2325
+ const known = new Set(before ? selectionReferences(before.document, { tolerant: true }).map(({ id, source }) => key(id, source)) : []);
2326
+ const added = selectionReferences(after.document, { tolerant: true }).filter(({ id, source }) => !known.has(key(id, source)));
2327
+ return {
2328
+ quotes: (await Promise.all(added.slice(0, QUOTE_LIMIT).map(async ({ source }) => {
2329
+ try {
2330
+ const pins = anchorPins(source, after.pins);
2331
+ const line = async (endpoint) => (await read(pins, endpoint.side, source.file)).split("\n")[endpoint.line - 1] ?? "";
2332
+ const first = clip(await line(source.start));
2333
+ return source.start.side === source.end.side && source.start.line === source.end.line ? {
2334
+ anchor: formatAnchor(source),
2335
+ first
2336
+ } : {
2337
+ anchor: formatAnchor(source),
2338
+ first,
2339
+ last: clip(await line(source.end))
2340
+ };
2341
+ } catch {
2342
+ return;
2343
+ }
2344
+ }))).filter((quote) => quote !== void 0),
2345
+ unquoted: Math.max(0, added.length - QUOTE_LIMIT)
2346
+ };
2347
+ }
2348
+ //#endregion
2349
+ //#region src/review-api/document-text.ts
2350
+ /** A reading view of saved content, not another document format to maintain. */
2351
+ function documentText(snapshot, targetId, full = false) {
2352
+ const target = targetId === void 0 ? void 0 : elements(snapshot.document).find((item) => item.id === targetId);
2353
+ if (targetId !== void 0 && !target) throw new ReviewInputError("Target not found in this version.", 404);
2354
+ const detailed = full || target !== void 0;
2355
+ const lines = [
2356
+ `# ${snapshot.title}`,
2357
+ `Session ${snapshot.reviewId} · version ${snapshot.version}`,
2358
+ ""
2359
+ ];
2360
+ const write = (depth, text) => {
2361
+ for (const line of text.split("\n")) lines.push(" ".repeat(depth) + line);
2362
+ };
2363
+ const code = (depth, value) => {
2364
+ write(depth, `Code (${value.language}):`);
2365
+ write(depth + 1, value.text);
2366
+ };
2367
+ const render = (element, depth) => {
2368
+ const title = "title" in element ? element.title : void 0;
2369
+ write(depth, `[${element.id}] ${element.type}${title ? `: ${title}` : ""}`);
2370
+ const detail = (text) => write(depth + 1, text);
2371
+ switch (element.type) {
2372
+ case "section":
2373
+ case "callout":
2374
+ if (element.type === "callout") detail(`Tone: ${element.tone}`);
2375
+ if (element.type === "section" && element.defaultCollapsed) detail("Initially collapsed");
2376
+ element.children.forEach((child) => render(child, depth + 1));
2377
+ break;
2378
+ case "markdown":
2379
+ detail(detailed ? element.markdown : element.markdown.split("\n")[0] + (element.markdown.includes("\n") ? " …" : ""));
2380
+ break;
2381
+ case "code":
2382
+ if (element.caption) detail(element.caption);
2383
+ if (detailed) code(depth + 1, element);
2384
+ else detail(`${element.language}, ${element.text.split("\n").length} lines`);
2385
+ break;
2386
+ case "divider": break;
2387
+ case "code_peek":
2388
+ detail(element.source);
2389
+ if (element.caption) detail(element.caption);
2390
+ break;
2391
+ case "sequence":
2392
+ for (const [key, label] of Object.entries(element.actors)) detail(`Actor ${key}: ${label}`);
2393
+ element.steps.forEach((step) => render(step, depth + 1));
2394
+ break;
2395
+ case "step":
2396
+ detail(`${element.from} → ${element.to}: ${element.label} (${element.style})`);
2397
+ if (element.source) detail(element.source);
2398
+ if (detailed && element.explanation) detail(element.explanation);
2399
+ if (detailed && element.code) code(depth + 1, element.code);
2400
+ break;
2401
+ case "call_stack_diff":
2402
+ for (const side of ["base", "head"]) {
2403
+ detail(`${side}:`);
2404
+ element[side].forEach((frame) => {
2405
+ write(depth + 2, `${frame.label ?? frame.key ?? "Frame"}: ${frame.source}`);
2406
+ if (detailed && frame.via) write(depth + 3, `${frame.via.kind}: ${frame.via.reason}`);
2407
+ });
2408
+ }
2409
+ break;
2410
+ case "database_lens":
2411
+ for (const [key, label] of Object.entries(element.actors)) detail(`Actor ${key}: ${label}`);
2412
+ for (const [key, store] of Object.entries(element.stores)) {
2413
+ detail(`Store ${key}: ${store.label} (${store.storage})`);
2414
+ for (const [key, collection] of Object.entries(store.collections)) {
2415
+ write(depth + 2, `Collection ${key}: ${collection.label}`);
2416
+ if (detailed) for (const [key, field] of Object.entries(collection.fields)) write(depth + 3, `${key}: ${field.label}, ${field.dataType}${field.primaryKey ? ", primary key" : ""}${field.nullable ? ", nullable" : ""}${field.references ? ` → ${field.references.store}.${field.references.collection}.${field.references.field}` : ""}`);
2417
+ }
2418
+ }
2419
+ for (const useCase of element.useCases) {
2420
+ detail(`Use case: ${useCase.label}`);
2421
+ if (detailed && useCase.summary) write(depth + 2, useCase.summary);
2422
+ for (const operation of useCase.operations) write(depth + 2, `${operation.actor}: ${operation.kind} ${operation.store}.${operation.collection}${operation.field ? `.${operation.field}` : ""} — ${operation.label} (${operation.source})`);
2423
+ }
2424
+ break;
2425
+ case "image":
2426
+ detail(`${element.alt} (asset: ${element.assetId})`);
2427
+ if (element.caption) detail(element.caption);
2428
+ break;
2429
+ case "trace_quote":
2430
+ detail(`${element.traceId}, event ${element.eventId}`);
2431
+ detail(element.text);
2432
+ break;
2433
+ case "flow_diagram":
2434
+ if (element.description) detail(element.description);
2435
+ for (const node of element.nodes) {
2436
+ detail(`[${node.id}] ${node.key}: ${node.label}`);
2437
+ if (detailed && node.description) write(depth + 2, node.description);
2438
+ for (const attachment of node.attachments) write(depth + 2, `${attachment.label}: ${attachment.sources.join(", ")}`);
2439
+ }
2440
+ for (const edge of element.edges) detail(`[${edge.id}] ${edge.from} → ${edge.to}${edge.label ? `: ${edge.label}` : ""}`);
2441
+ break;
2442
+ case "software_map": detail(`Map: ${element.mapVersionId}${element.focusElementId ? `, focus: ${element.focusElementId}` : ""}`);
2443
+ }
2444
+ };
2445
+ (target ? [target] : snapshot.document).forEach((element) => render(element, 0));
2446
+ if (!snapshot.document.length) lines.push("(Empty review)");
2447
+ if (!target && snapshot.lenses?.length) {
2448
+ lines.push("", "Lenses (edit with session_lens_edit):");
2449
+ for (const lens of snapshot.lenses) {
2450
+ write(0, `[${lens.id}] ${lens.title}`);
2451
+ for (const item of lens.targets) if (item.kind === "files") write(1, `Files: ${item.patterns.join(", ")}`);
2452
+ else for (const source of item.sources) write(1, `Range: ${source}`);
2453
+ }
2454
+ }
2455
+ if (!detailed && snapshot.document.length) lines.push("", "Use targetId for one component's complete content, or full:true for the whole review.");
2456
+ return lines.join("\n");
2457
+ }
2458
+ //#endregion
2459
+ //#region src/review-api/file-lenses.ts
2460
+ /** Subtract authored coverage, independently of viewed state. */
2461
+ function uncategorizedSources(files, sources) {
2462
+ return files.flatMap((file) => {
2463
+ const covered = scopedCoverage(file, sources);
2464
+ return coverageSources(file, {
2465
+ base: subtractIntervals(file.changed.base, covered.base),
2466
+ head: subtractIntervals(file.changed.head, covered.head)
2467
+ });
2468
+ });
2469
+ }
2470
+ /** Resolve all targets into one union; a whole-file range subsumes narrower ranges. */
2471
+ function resolveFileLens(lens, files, fileSources, resolve) {
2472
+ const targets = lens.targets;
2473
+ const selected = targets.flatMap((target) => target.kind === "ranges" ? target.sources.flatMap((source) => resolve(anchorSelection(source))) : files.filter((file) => matchesFileLens(target.patterns, file)).flatMap((file) => fileSources.get(file.path) ?? []));
2474
+ const groups = /* @__PURE__ */ new Map();
2475
+ for (const source of selected) {
2476
+ const key = JSON.stringify([source.side, source.file]);
2477
+ const group = groups.get(key) ?? [];
2478
+ group.push(source);
2479
+ groups.set(key, group);
2480
+ }
2481
+ const sources = [...groups.values()].flatMap((group) => unionIntervals(group.map((source) => [source.fromLine - 1, source.toLine])).map(([start, end]) => ({
2482
+ ...group[0],
2483
+ fromLine: start + 1,
2484
+ toLine: end
2485
+ })));
2486
+ return {
2487
+ sources,
2488
+ fileCount: new Set(sources.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
2489
+ wholeFiles: targets.every((target) => target.kind === "files")
2490
+ };
2491
+ }
2492
+ /** Evaluate authored patterns against the changed-file list, never the filesystem. */
2493
+ function matchesFileLens(patterns, file) {
2494
+ return patterns.some((pattern) => {
2495
+ const normalized = pattern.startsWith("./") ? pattern.slice(2) : pattern;
2496
+ return [file.path, file.previousPath].some((path) => path !== void 0 && (path === normalized || posix.matchesGlob(path, normalized)));
2497
+ });
2498
+ }
2499
+ //#endregion
2500
+ //#region src/review-api/review-progress.ts
2501
+ const coverageModeSchema = z.enum(["structural", "textual"]).default("structural");
2502
+ const progressUpdateSchema = z.strictObject({
2503
+ mode: coverageModeSchema,
2504
+ version: z.number().int().nonnegative(),
2505
+ files: z.array(z.strictObject({
2506
+ path: z.string(),
2507
+ fingerprint: z.string().min(1),
2508
+ sources: z.array(fileLineRangeSchema)
2509
+ })).max(1e4),
2510
+ viewed: z.boolean()
2511
+ });
2512
+ /** Coverage survives new pins only when both complete file contents are unchanged. */
2513
+ async function reviewProgress(store, data, snapshot, signal = new AbortController().signal, mode = "structural", partial) {
2514
+ const marks = snapshot.shared ? /* @__PURE__ */ new Map() : store.viewedCoverage(snapshot.reviewId);
2515
+ const pins = snapshot.pins ? (await data.resolveSource(snapshot)).pins : void 0;
2516
+ const comparison = pins ? partial ?? await data.coverage(snapshot.reviewId, pins, mode) : {
2517
+ files: [],
2518
+ fileSources: /* @__PURE__ */ new Map(),
2519
+ alignments: /* @__PURE__ */ new Map()
2520
+ };
2521
+ signal.throwIfAborted();
2522
+ const documentKey = JSON.stringify(pins);
2523
+ const comparisons = /* @__PURE__ */ new Map();
2524
+ /** The comparison a reference's own pins name, shared across references. */
2525
+ const ownComparison = (own) => {
2526
+ const key = JSON.stringify(own);
2527
+ let loading = comparisons.get(key);
2528
+ if (loading) return loading;
2529
+ if (partial) {
2530
+ if (data.coverageSnapshot(snapshot.reviewId, own, mode).pending) return void 0;
2531
+ }
2532
+ loading = data.coverage(snapshot.reviewId, own, mode);
2533
+ comparisons.set(key, loading);
2534
+ return loading;
2535
+ };
2536
+ const withViewed = (file) => ({
2537
+ ...file,
2538
+ viewed: marks.get(file.path)?.fingerprint === file.fingerprint ? marks.get(file.path).coverage : emptyCoverage()
2539
+ });
2540
+ const files = comparison.files.map(withViewed);
2541
+ const referenceFiles = {};
2542
+ const fileSources = new Map(comparison.fileSources);
2543
+ const alignments = new Map(comparison.alignments);
2544
+ const resolvedSelections = {};
2545
+ const unavailableSelections = {};
2546
+ const pendingSources = /* @__PURE__ */ new Set();
2547
+ const resolve = async (source) => {
2548
+ const key = selectionKey(source);
2549
+ if (resolvedSelections[key]) return resolvedSelections[key];
2550
+ const sourcePins = anchorPins(source, pins);
2551
+ const own = JSON.stringify(sourcePins) !== documentKey;
2552
+ let ownFiles = files;
2553
+ let ownAlignments = alignments;
2554
+ if (own) {
2555
+ const loading = ownComparison(sourcePins);
2556
+ if (!loading) {
2557
+ pendingSources.add(key);
2558
+ return [];
2559
+ }
2560
+ const loaded = await loading;
2561
+ ownFiles = loaded.files;
2562
+ ownAlignments = loaded.alignments;
2563
+ }
2564
+ const file = ownFiles.find((file) => source.file === file.path || source.file === file.previousPath);
2565
+ if (own) {
2566
+ const group = referenceFiles[comparisonKey(sourcePins)] ??= {};
2567
+ if (file) group[file.path] ??= withViewed(file);
2568
+ }
2569
+ let rows = file && ownAlignments.get(file.path);
2570
+ if (!file && partial && !own) {
2571
+ pendingSources.add(key);
2572
+ return [];
2573
+ }
2574
+ if (!file) {
2575
+ const [base, head] = await Promise.all([data.file(sourcePins, "base", source.file), data.file(sourcePins, "head", source.file)]);
2576
+ if (base.text !== head.text) throw new Error("Changed context file has no alignment.");
2577
+ rows = Array.from({ length: base.text.split("\n").length }, (_, line) => [line, line]);
2578
+ }
2579
+ if (!rows) throw new Error("File has no text alignment.");
2580
+ const result = resolveDiffSelection(source, rows, file ?? { path: source.file });
2581
+ resolvedSelections[key] = result;
2582
+ return result;
2583
+ };
2584
+ const resolveAvailable = async (source) => {
2585
+ try {
2586
+ return await resolve(source);
2587
+ } catch {
2588
+ unavailableSelections[selectionKey(source)] = "Source links need updating at these pins";
2589
+ return [];
2590
+ }
2591
+ };
2592
+ const lenses = await Promise.all((snapshot.lenses ?? []).map(async (authored) => {
2593
+ const lens = {
2594
+ id: authored.id,
2595
+ title: authored.title
2596
+ };
2597
+ try {
2598
+ await Promise.all(lensSelections([authored]).map(({ source }) => resolveAvailable(source)));
2599
+ const resolved = resolveFileLens(authored, files, fileSources, (source) => resolvedSelections[selectionKey(source)] ?? []);
2600
+ return {
2601
+ ...lens,
2602
+ ...resolved,
2603
+ pending: !!partial,
2604
+ unavailable: partial || resolved.fileCount ? void 0 : "No files match these targets"
2605
+ };
2606
+ } catch {
2607
+ return {
2608
+ ...lens,
2609
+ sources: [],
2610
+ unavailable: "Source links need updating at these pins"
2611
+ };
2612
+ }
2613
+ }));
2614
+ await Promise.all(selectionReferences(snapshot.document, { tolerant: true }).map(async ({ source }) => {
2615
+ try {
2616
+ await resolveAvailable(source);
2617
+ } catch {}
2618
+ }));
2619
+ const uncategorized = partial ? [] : uncategorizedSources(files, lenses.filter((lens) => !lens.unavailable).flatMap((lens) => lens.sources));
2620
+ lenses.push({
2621
+ id: UNCATEGORIZED_LENS_ID,
2622
+ title: "Uncategorized changes",
2623
+ sources: uncategorized,
2624
+ wholeFiles: false,
2625
+ fileCount: new Set(uncategorized.map((source) => files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file)).size,
2626
+ pending: !!partial,
2627
+ unavailable: partial || uncategorized.length ? void 0 : "All changed lines are covered by lenses"
2628
+ });
2629
+ const progress = {
2630
+ complete: !partial,
2631
+ files,
2632
+ referenceFiles,
2633
+ lenses,
2634
+ resolvedSelections,
2635
+ unavailableSelections
2636
+ };
2637
+ const untrackedFiles = pins ? await data.untrackedFiles(pins) : void 0;
2638
+ if (untrackedFiles) progress.untrackedFiles = untrackedFiles;
2639
+ return progress;
2640
+ }
2641
+ /** The most files an uncategorized report names; the rest are counted. */
2642
+ const REPORT_FILES = 50;
2643
+ /** What a lens author still has to place: the changed lines no lens selects,
2644
+ * grouped by file. */
2645
+ function uncategorizedReport(progress) {
2646
+ const sources = progress.lenses.find((lens) => lens.id === "automatic-uncategorized")?.sources ?? [];
2647
+ const byPath = /* @__PURE__ */ new Map();
2648
+ for (const source of sources) {
2649
+ const path = progress.files.find((file) => source.file === (source.side === "base" ? file.previousPath ?? file.path : file.path))?.path ?? source.file;
2650
+ const entry = byPath.get(path) ?? {
2651
+ path,
2652
+ lines: 0,
2653
+ ranges: []
2654
+ };
2655
+ entry.lines += source.toLine - source.fromLine + 1;
2656
+ entry.ranges.push({
2657
+ side: source.side,
2658
+ fromLine: source.fromLine,
2659
+ toLine: source.toLine
2660
+ });
2661
+ byPath.set(path, entry);
2662
+ }
2663
+ const files = [...byPath.values()];
2664
+ const report = {
2665
+ lines: files.reduce((total, file) => total + file.lines, 0),
2666
+ files: files.slice(0, REPORT_FILES)
2667
+ };
2668
+ if (files.length > REPORT_FILES) report.moreFiles = files.length - REPORT_FILES;
2669
+ return report;
2670
+ }
2671
+ /** Each lens as authored, with what it resolves to, for a lens author. */
2672
+ function lensReport(lenses, progress) {
2673
+ return {
2674
+ lenses: lenses.map((lens) => {
2675
+ const resolved = progress.lenses.find((item) => item.id === lens.id);
2676
+ return {
2677
+ ...lens,
2678
+ fileCount: resolved?.fileCount ?? 0,
2679
+ ...resolved?.unavailable && { unavailable: resolved.unavailable }
2680
+ };
2681
+ }),
2682
+ uncategorized: uncategorizedReport(progress)
2683
+ };
2684
+ }
2685
+ //#endregion
2686
+ //#region src/review-api/traces.ts
2687
+ async function listPinnedTraces(cwd, pins, override) {
2688
+ const selection = selectTraceStorage();
2689
+ const sources = [];
2690
+ if (selection.s3?.credentials || isS3MockMode()) sources.push("s3");
2691
+ if (selection.hosted) sources.push("hosted");
2692
+ const result = {
2693
+ ok: true,
2694
+ configured: isTraceStorageConfigured(),
2695
+ storage: override ?? selection.mode,
2696
+ sources
2697
+ };
2698
+ try {
2699
+ if (selection.error) return {
2700
+ ...result,
2701
+ storageError: selection.error,
2702
+ sessions: []
2703
+ };
2704
+ const storage = await resolveTraceStorage({
2705
+ cwd,
2706
+ override
2707
+ });
2708
+ if (!storage && (override ?? selection.mode) === "hosted") return {
2709
+ ...result,
2710
+ storageError: "The hosted trace store has no login on this machine. Run `whiteboard login` and open the review again.",
2711
+ sessions: []
2712
+ };
2713
+ return {
2714
+ ...result,
2715
+ sessions: await listReviewTraceSessions({
2716
+ rootPath: cwd,
2717
+ baseCommit: pins.base,
2718
+ headCommit: pins.head,
2719
+ storage
2720
+ })
2721
+ };
2722
+ } catch (error) {
2723
+ if (error instanceof TraceStorageDeniedError || error instanceof TraceConfigurationError) return {
2724
+ ...result,
2725
+ storageError: error.message,
2726
+ sessions: []
2727
+ };
2728
+ throw error;
2729
+ }
2730
+ }
2731
+ async function readStoredTrace(cwd, sessionId, trace, override) {
2732
+ try {
2733
+ const storage = await resolveTraceStorage({
2734
+ cwd,
2735
+ override
2736
+ });
2737
+ const loaded = await loadReviewAgentTrace({
2738
+ cwd,
2739
+ sessionId,
2740
+ trace,
2741
+ storage
2742
+ });
2743
+ if (!loaded) return {
2744
+ ok: false,
2745
+ status: 404,
2746
+ error: `Trace not found for session ${sessionId}.`
2747
+ };
2748
+ return {
2749
+ ok: true,
2750
+ parserVersion: loaded.parserVersion,
2751
+ session: loaded.descriptor,
2752
+ trace: loaded.traceName,
2753
+ cacheStatus: loaded.cacheStatus,
2754
+ subagents: loaded.subagents,
2755
+ title: loaded.trace.title,
2756
+ startedAt: loaded.trace.startedAt,
2757
+ endedAt: loaded.trace.endedAt,
2758
+ activeMs: loaded.trace.activeMs,
2759
+ userTurns: loaded.trace.userTurns,
2760
+ toolCalls: loaded.trace.toolCalls,
2761
+ events: loaded.trace.events
2762
+ };
2763
+ } catch (error) {
2764
+ if (error instanceof TraceStorageDeniedError || error instanceof TraceConfigurationError) return {
2765
+ ok: false,
2766
+ status: error instanceof TraceStorageDeniedError ? 403 : 400,
2767
+ error: error.message
2768
+ };
2769
+ throw error;
2770
+ }
2771
+ }
2772
+ //#endregion
2773
+ //#region src/review-api/http.ts
2774
+ const askStartSchema = z.strictObject({
2775
+ agent: z.enum(askAgentIds),
2776
+ question: askQuestionSchema,
2777
+ selection: AgentSelectionSchema,
2778
+ picks: askPicksSchema.optional(),
2779
+ bypass: z.boolean().optional()
2780
+ });
2781
+ const askPermitSchema = z.strictObject({ bypass: z.boolean() });
2782
+ const askOpenSchema = z.strictObject({ picks: askPicksSchema.optional() });
2783
+ const askChoiceSchema = z.strictObject({
2784
+ kind: z.enum(askChoiceKinds),
2785
+ value: z.string().min(1).max(200)
2786
+ });
2787
+ const askFilesSchema = z.strictObject({ paths: z.array(z.string().min(1).max(400)).max(100) });
2788
+ const askFollowUpSchema = z.strictObject({ question: askQuestionSchema });
2789
+ /** A question with its images: four of up to 5 MB each, base64. */
2790
+ const ASK_REQUEST_MAX_BYTES = 31457280;
2791
+ const askMentionsSchema = z.object({
2792
+ query: z.string().max(400).default(""),
2793
+ /** A conversation's checkout, which can be an earlier version's. */
2794
+ thread: z.string().optional()
2795
+ });
2796
+ const askOfferQuerySchema = z.object({
2797
+ /** The model picked for a question not yet asked. */
2798
+ model: askPicksSchema.shape.model });
2799
+ /** How many files a mention picker shows. */
2800
+ const MENTION_LIMIT = 20;
2801
+ const askDecisionSchema = z.strictObject({
2802
+ permissionId: z.string().min(1),
2803
+ optionId: z.string().min(1)
2804
+ });
2805
+ const SCRATCHPAD_DISABLED = "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
2806
+ /** A gateway forwarding from another machine; it gets no local paths. */
2807
+ const remoteCaller = (context) => context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
2808
+ /** Both hosts mount this behind their token authentication. */
2809
+ function createReviewApi(store, data, open, shared, capabilities = () => ({
2810
+ desktopAvailable: Boolean(open),
2811
+ softwareMapEnabled: false
2812
+ }), scratchpadEnabled = () => false, traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
2813
+ const app = new Hono();
2814
+ app.onError((error, context) => {
2815
+ if (error instanceof HttpJsonError) return context.json({ error: error.message }, error.statusCode);
2816
+ if (error instanceof ReviewInputError) return context.json({ error: error.message }, error.status);
2817
+ if (error instanceof z.ZodError) return context.json({
2818
+ error: z.prettifyError(error),
2819
+ issues: error.issues
2820
+ }, 400);
2821
+ console.error(`[Review API] ${context.req.method} ${context.req.path} failed:`, error);
2822
+ return context.json({ error: `Whiteboard operation failed (${failureKind(error)}). The server logged the cause; Whiteboard Desktop writes it to main.log in its logs folder.` }, 500);
2823
+ });
2824
+ if (data) app.use("*", async (context, next) => {
2825
+ if (context.req.method === "GET" && !context.req.query("version")) await store.refreshWorktrees();
2826
+ await next();
2827
+ });
2828
+ const sharedData = shared ? new SharedReviewData(shared) : void 0;
2829
+ const isShared = (id) => id.startsWith("shared-");
2830
+ const sharedCommandSchema = z.object({ operation: z.object({ reviewId: z.string().optional() }) });
2831
+ const ensureScratchpad = async (id) => {
2832
+ if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID)) await store.ensureScratchpad();
2833
+ };
2834
+ const refuseDisabledScratchpad = (id) => {
2835
+ if (id === SCRATCHPAD_ID && !scratchpadEnabled()) throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
2836
+ };
2837
+ const sharedGuard = async (context, next) => {
2838
+ refuseDisabledScratchpad(context.req.param("id"));
2839
+ await ensureScratchpad(context.req.param("id"));
2840
+ const id = context.req.param("id");
2841
+ if (!id || !isShared(id)) return next();
2842
+ const query = readQuerySchemas.get.parse({ version: context.req.query("version") });
2843
+ readReview(id, query.version);
2844
+ if (context.req.method !== "GET" && !/\/(open|source|copy-context|environment)$/.test(context.req.path) && !/\/workspaces\/[^/]+\/retry$/.test(context.req.path)) throw new ReviewInputError("Shared reviews are read-only.", 409);
2845
+ return next();
2846
+ };
2847
+ app.use("/:id", sharedGuard);
2848
+ app.use("/:id/*", sharedGuard);
2849
+ if (shared && data) {
2850
+ shared.connect(store, data);
2851
+ mountSharingHost(app, store, data, shared, hooks.sharing);
2852
+ }
2853
+ const readReview = (id, version) => {
2854
+ if (!id.startsWith("shared-")) return store.read(id, version);
2855
+ const snapshot = shared?.get(id).snapshot;
2856
+ if (!snapshot || version !== void 0 && version !== snapshot.version) throw new ReviewInputError("Shared review version is unavailable.", 404);
2857
+ return snapshot;
2858
+ };
2859
+ const catalog = (mode = "structural") => {
2860
+ const local = store.list(mode);
2861
+ return [...scratchpadEnabled() ? local : local.filter((summary) => summary.kind !== "scratchpad"), ...shared?.list(mode) ?? []];
2862
+ };
2863
+ app.get("/", async (context) => {
2864
+ await ensureScratchpad();
2865
+ return context.json(catalog(coverageModeSchema.parse(context.req.query("mode"))));
2866
+ });
2867
+ const instructionContext = async () => ({
2868
+ desktopAvailable: Boolean(open),
2869
+ scratchpadEnabled: scratchpadEnabled(),
2870
+ traceEnabled: await traceEnabled()
2871
+ });
2872
+ app.get("/authoring", async (context) => {
2873
+ const instructions = await instructionContext();
2874
+ return context.json(authoringTools(scratchpadAvailable(instructions), instructions.traceEnabled));
2875
+ });
2876
+ app.get("/instructions", async (context) => {
2877
+ const { topic } = instructionsQuerySchema.parse(context.req.query());
2878
+ return context.json(await renderInstructions(topic, await instructionContext()));
2879
+ });
2880
+ app.get("/:id/progress", async (context) => {
2881
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
2882
+ const query = readQuerySchemas.get.pick({ version: true }).extend({
2883
+ mode: coverageModeSchema,
2884
+ wait: z.enum(["false", "true"]).default("true")
2885
+ }).parse(context.req.query());
2886
+ const snapshot = readReview(context.req.param("id"), query.version);
2887
+ const documentPins = query.wait === "false" && snapshot.pins ? (await data.resolveSource(snapshot)).pins : void 0;
2888
+ if (documentPins) {
2889
+ const state = data.coverageSnapshot(snapshot.reviewId, documentPins, query.mode);
2890
+ if (state.pending) return context.json(await reviewProgress(store, data, snapshot, context.req.raw.signal, query.mode, state.comparison), 202);
2891
+ }
2892
+ return context.json(await reviewProgress(store, data, snapshot, context.req.raw.signal, query.mode));
2893
+ });
2894
+ app.post("/:id/progress", async (context) => {
2895
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
2896
+ const input = progressUpdateSchema.parse(await readBoundedRequestJson(context.req.raw));
2897
+ const id = context.req.param("id");
2898
+ const snapshot = store.read(id);
2899
+ const progress = await reviewProgress(store, data, snapshot, context.req.raw.signal, input.mode);
2900
+ const files = input.files.map((update) => {
2901
+ const file = progress.files.find((file) => file.path === update.path);
2902
+ if (!file || file.fingerprint !== update.fingerprint) throw new ReviewInputError("This file changed. Reload before marking it viewed.", 409);
2903
+ return {
2904
+ path: file.path,
2905
+ fingerprint: file.fingerprint,
2906
+ scope: scopedCoverage(file, update.sources)
2907
+ };
2908
+ });
2909
+ if (store.read(id).version !== snapshot.version) throw new ReviewInputError("Review changed during this update. Try again.", 409);
2910
+ store.updateViewedCoverage(id, files, input.viewed);
2911
+ return context.json(await reviewProgress(store, data, store.read(id, input.version), context.req.raw.signal, input.mode));
2912
+ });
2913
+ app.get("/:id/lenses", async (context) => {
2914
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
2915
+ const snapshot = readReview(context.req.param("id"));
2916
+ return context.json({
2917
+ version: snapshot.version,
2918
+ ...lensReport(snapshot.lenses ?? [], await reviewProgress(store, data, snapshot, context.req.raw.signal))
2919
+ });
2920
+ });
2921
+ app.get("/status", async (context) => context.json({
2922
+ ...status(),
2923
+ desktopAvailable: (await capabilities()).desktopAvailable
2924
+ }));
2925
+ app.get("/capabilities", async (context) => context.json({
2926
+ ...await capabilities(),
2927
+ scratchpadEnabled: scratchpadEnabled()
2928
+ }));
2929
+ app.get("/:id/activity", (context) => {
2930
+ const id = context.req.param("id");
2931
+ readReview(id);
2932
+ return context.json(isShared(id) ? {
2933
+ workingCount: 0,
2934
+ expiresAt: null
2935
+ } : store.activity.read(id));
2936
+ });
2937
+ for (const action of [
2938
+ "begin",
2939
+ "update",
2940
+ "end"
2941
+ ]) app.post(`/:id/activity/${action}`, async (context) => {
2942
+ const input = await readBoundedRequestJson(context.req.raw);
2943
+ const id = context.req.param("id");
2944
+ store.assertExists(id);
2945
+ return context.json(store.activity.update(id, isJsonObject(input) ? {
2946
+ ...input,
2947
+ action
2948
+ } : input));
2949
+ });
2950
+ app.get("/watch", async (context) => {
2951
+ const query = context.req.query("subscriptions");
2952
+ if (query !== void 0) {
2953
+ let input;
2954
+ try {
2955
+ input = JSON.parse(query);
2956
+ } catch {
2957
+ throw new ReviewInputError("Invalid subscriptions.");
2958
+ }
2959
+ const subscriptions = z.array(z.strictObject({
2960
+ reviewId: z.string().min(1).nullable(),
2961
+ mode: coverageModeSchema
2962
+ })).parse(input);
2963
+ const dirty = new Set(subscriptions.keys());
2964
+ const mark = (id) => {
2965
+ let marked = false;
2966
+ subscriptions.forEach((item, index) => {
2967
+ if (item.reviewId === id) {
2968
+ dirty.add(index);
2969
+ marked = true;
2970
+ }
2971
+ });
2972
+ return marked;
2973
+ };
2974
+ return watch$1(() => subscriptions.map(({ reviewId, mode }, index) => {
2975
+ if (!dirty.delete(index)) return null;
2976
+ try {
2977
+ return { value: reviewId === null ? catalog(mode) : {
2978
+ ...readReview(reviewId),
2979
+ activity: store.activity.read(reviewId),
2980
+ coverageRevision: data?.coverageRevision ?? 0
2981
+ } };
2982
+ } catch (error) {
2983
+ return { error: error instanceof ReviewInputError ? error.message : "Could not read review." };
2984
+ }
2985
+ }), (notify) => {
2986
+ const stops = [
2987
+ store.watchWorktrees(),
2988
+ data?.subscribeCoverage(() => {
2989
+ subscriptions.forEach((item, index) => {
2990
+ if (item.reviewId !== null) dirty.add(index);
2991
+ });
2992
+ if (dirty.size > 0) notify();
2993
+ }) ?? (() => {}),
2994
+ store.subscribe((result) => {
2995
+ if (mark(result.reviewId)) notify();
2996
+ }),
2997
+ store.activity.subscribe((id) => {
2998
+ if (mark(id)) notify();
2999
+ }),
3000
+ store.activity.subscribeWorking(() => {
3001
+ if (mark(null)) notify();
3002
+ }),
3003
+ shared?.subscribe(() => {
3004
+ if (mark(null)) notify();
3005
+ }) ?? (() => {}),
3006
+ store.subscribeCatalog(() => {
3007
+ if (mark(null)) notify();
3008
+ })
3009
+ ];
3010
+ return () => stops.forEach((stop) => stop());
3011
+ }, () => {});
3012
+ }
3013
+ await ensureScratchpad();
3014
+ return watch$1(() => catalog(coverageModeSchema.parse(context.req.query("mode"))), (notify) => {
3015
+ const local = store.subscribeCatalog(notify);
3016
+ const activity = store.activity.subscribeWorking(notify);
3017
+ const imported = shared?.subscribe(notify);
3018
+ return () => {
3019
+ local();
3020
+ activity();
3021
+ imported?.();
3022
+ };
3023
+ });
3024
+ });
3025
+ /** Show a review in Desktop and start preparing its pinned checkouts. */
3026
+ const openReview = async (review) => {
3027
+ if (!open) throw new ReviewInputError("The desktop is not connected.", 409);
3028
+ const settings = await open({
3029
+ reviewId: review.reviewId,
3030
+ title: review.title
3031
+ });
3032
+ let environmentIssues;
3033
+ try {
3034
+ if (review.target?.kind === "commits" && review.pins) data?.workspaces.open(review.reviewId, review.pins).catch(() => {});
3035
+ environmentIssues = data?.currentEnvironmentIssues(review);
3036
+ } catch (error) {
3037
+ environmentIssues = [{ message: `Could not check language checkouts: ${errorMessage(error)}. Recheck with review_environment.` }];
3038
+ }
3039
+ return {
3040
+ ...settings,
3041
+ environmentIssues: environmentIssues?.length ? environmentIssues : void 0
3042
+ };
3043
+ };
3044
+ /**
3045
+ * A created or returned review is shown where Desktop can, unless the author asked
3046
+ * not to. The review is already saved, so a failed open is reported, not thrown.
3047
+ */
3048
+ const openCreated = async (reviewId) => {
3049
+ try {
3050
+ if (!open || !(await capabilities()).desktopAvailable) return { opened: false };
3051
+ return {
3052
+ opened: true,
3053
+ ...await openReview(store.read(reviewId))
3054
+ };
3055
+ } catch (error) {
3056
+ return {
3057
+ opened: false,
3058
+ openError: `${errorMessage(error)} Retry with review_open.`
3059
+ };
3060
+ }
3061
+ };
3062
+ app.post("/:id/open", async (context) => {
3063
+ const id = context.req.param("id");
3064
+ if (isShared(id)) await shared?.assertReady(id);
3065
+ return context.json({
3066
+ ok: true,
3067
+ ...await openReview(readReview(id))
3068
+ });
3069
+ });
3070
+ app.get("/:id/watch", (context) => {
3071
+ const id = context.req.param("id");
3072
+ let document;
3073
+ return watch$1(() => ({
3074
+ ...document ??= readReview(id),
3075
+ activity: isShared(id) ? {
3076
+ workingCount: 0,
3077
+ expiresAt: null
3078
+ } : store.activity.read(id)
3079
+ }), (notify) => {
3080
+ const stopRefresh = store.watchWorktrees();
3081
+ const stopDocument = store.subscribe((result) => {
3082
+ if (result.reviewId === id) {
3083
+ document = void 0;
3084
+ notify();
3085
+ }
3086
+ });
3087
+ const stopActivity = store.activity.subscribe((changed) => {
3088
+ if (changed === id) notify();
3089
+ });
3090
+ return () => {
3091
+ stopRefresh();
3092
+ stopDocument();
3093
+ stopActivity();
3094
+ };
3095
+ });
3096
+ });
3097
+ if (data) {
3098
+ const traceQuery = readQuerySchemas.maps.extend({
3099
+ storage: z.enum(["s3", "hosted"]).optional(),
3100
+ trace: z.string().min(1).optional()
3101
+ });
3102
+ const tracePins = (id, version) => {
3103
+ const { pins } = readReview(id, version);
3104
+ if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
3105
+ return pins;
3106
+ };
3107
+ app.get("/:id/agent-traces", async (context) => {
3108
+ const query = traceQuery.parse(context.req.query());
3109
+ const pins = tracePins(context.req.param("id"), query.version);
3110
+ return context.json(await listPinnedTraces(store.repositoryPath(pins.repositoryId), pins, query.storage));
3111
+ });
3112
+ app.get("/:id/agent-traces/:sessionId", async (context) => {
3113
+ const query = traceQuery.parse(context.req.query());
3114
+ const pins = tracePins(context.req.param("id"), query.version);
3115
+ const result = await readStoredTrace(store.repositoryPath(pins.repositoryId), context.req.param("sessionId"), query.trace, query.storage);
3116
+ if (!result.ok) return context.json({
3117
+ ok: false,
3118
+ error: result.error
3119
+ }, result.status);
3120
+ return context.json(result);
3121
+ });
3122
+ app.post("/:id/navigator", async (context) => {
3123
+ if (remoteCaller(context)) throw new ReviewInputError("Source windows are not available for a review on another machine.", 409);
3124
+ const input = readQuerySchemas.file.extend({
3125
+ side: z.enum(["base", "head"]).default("head"),
3126
+ file: z.string().min(1).optional(),
3127
+ empty: z.literal("true").optional()
3128
+ }).parse(context.req.query());
3129
+ return context.json(await data.navigatorWorkspace(readReview(context.req.param("id"), input.version), {
3130
+ ...input,
3131
+ empty: input.empty === "true",
3132
+ anchor: queryAnchor(input)
3133
+ }));
3134
+ });
3135
+ app.get("/:id/tree", async (context) => {
3136
+ const input = readQuerySchemas.tree.parse(context.req.query());
3137
+ const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
3138
+ return context.json(await data.tree(pins, input.side, input.path));
3139
+ });
3140
+ app.get("/:id/maps/:resourceId", async (context) => {
3141
+ const query = readQuerySchemas.maps.parse(context.req.query());
3142
+ const id = context.req.param("id");
3143
+ if (id && isShared(id)) return context.json(sharedData.map(id, z.string().parse(context.req.param("resourceId"))));
3144
+ return context.json(await data.map(await data.sourcePins(readReview(id, query.version)), z.string().parse(context.req.param("resourceId"))));
3145
+ });
3146
+ app.post("/repositories", async (context) => {
3147
+ const input = z.strictObject({ path: z.string().min(1) }).parse(await readBoundedRequestJson(context.req.raw));
3148
+ return context.json(await data.register(input.path));
3149
+ });
3150
+ app.post("/resources", async (context) => context.json(await data.upload(await readBoundedRequestJson(context.req.raw, 8388608))));
3151
+ app.get("/:id/resources/:resourceId", async (context) => {
3152
+ const id = context.req.param("id");
3153
+ const snapshot = readReview(id);
3154
+ const resource = id && isShared(id) ? {
3155
+ ...await sharedData.resource(id, z.string().parse(context.req.param("resourceId"))),
3156
+ repositoryId: snapshot.pins?.repositoryId ?? ""
3157
+ } : store.resource(z.string().parse(context.req.param("resourceId")));
3158
+ if (snapshot.pins && resource.repositoryId !== snapshot.pins.repositoryId) throw new ReviewInputError("Resource is outside this repository.", 404);
3159
+ return new Response(Buffer.from(resource.data), { headers: {
3160
+ "content-type": resource.mimeType,
3161
+ "x-content-type-options": "nosniff"
3162
+ } });
3163
+ });
3164
+ app.get("/:id/language-context", async (context) => {
3165
+ const input = readQuerySchemas.maps.extend({
3166
+ side: z.enum(["base", "head"]).default("head"),
3167
+ commit: z.string().optional(),
3168
+ repositoryId: z.string().optional(),
3169
+ head: z.string().optional(),
3170
+ base: z.string().optional()
3171
+ }).parse(context.req.query());
3172
+ const snapshot = readReview(context.req.param("id"), input.version);
3173
+ const environment = await data.languageEnvironment(snapshot, input.side, input.commit, false, queryAnchor(input));
3174
+ return context.json(remoteCaller(context) ? {
3175
+ identity: createHash("sha256").update(environment.identity).digest("hex"),
3176
+ ...environment.issue && { issue: "The checkout for language features is not available on the remote machine." }
3177
+ } : environment);
3178
+ });
3179
+ app.post("/:id/environment", async (context) => {
3180
+ const input = z.strictObject({ retry: z.boolean().optional() }).parse(await readBoundedRequestJson(context.req.raw));
3181
+ return context.json({ issues: await data.environmentIssues(readReview(context.req.param("id")), input.retry) });
3182
+ });
3183
+ app.post("/workspace-cleanup", async (context) => {
3184
+ const input = z.strictObject({ workspaceId: z.string().min(1).optional() }).parse(await readBoundedRequestJson(context.req.raw));
3185
+ if (input.workspaceId) await data.workspaces.retryCleanup(input.workspaceId);
3186
+ return context.json({ failures: data.workspaces.failures() });
3187
+ });
3188
+ app.get("/:id/workspaces", (context) => {
3189
+ readReview(context.req.param("id"));
3190
+ return context.json(data.workspaces.list(context.req.param("id")));
3191
+ });
3192
+ app.post("/:id/workspaces/:workspaceId/retry", async (context) => {
3193
+ return context.json(await data.workspaces.retry(context.req.param("id"), context.req.param("workspaceId")));
3194
+ });
3195
+ app.get("/:id/file", async (context) => {
3196
+ const input = readQuerySchemas.file.extend({ binary: z.literal("describe").optional() }).parse(context.req.query());
3197
+ const id = context.req.param("id");
3198
+ const anchor = queryAnchor(input);
3199
+ const { snapshot, pins } = await data.resolveSource(readReview(id, input.version), input.commit, anchor);
3200
+ const file = await data.file(pins, input.side, input.file, input.binary === "describe");
3201
+ if (file.text.includes("\0")) return context.json({
3202
+ binary: true,
3203
+ file: file.file,
3204
+ side: file.side,
3205
+ commit: file.commit
3206
+ });
3207
+ const local = !remoteCaller(context) && !input.commit && !anchor && input.side === "head" && snapshot.target?.kind === "worktree" ? await data.liveFile(pins.repositoryId, input.file, file.text) : void 0;
3208
+ return context.json({
3209
+ ...file,
3210
+ ...local
3211
+ });
3212
+ });
3213
+ app.get("/:id/structural-diff", async (context) => {
3214
+ const input = readQuerySchemas.structuralDiff.parse(context.req.query());
3215
+ const id = context.req.param("id");
3216
+ const { pins } = await data.resolveSource(readReview(id, input.version), input.commit, queryAnchor(input));
3217
+ const abort = new AbortController();
3218
+ const encoder = new TextEncoder();
3219
+ const stream = new ReadableStream({
3220
+ async start(controller) {
3221
+ const send = (event) => {
3222
+ if (!abort.signal.aborted) controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
3223
+ };
3224
+ try {
3225
+ for await (const event of data.structuralChanges({
3226
+ reviewId: id,
3227
+ pins,
3228
+ signal: AbortSignal.any([context.req.raw.signal, abort.signal]),
3229
+ file: input.file
3230
+ })) send(event);
3231
+ } catch (error) {
3232
+ send({
3233
+ type: "error",
3234
+ message: error instanceof Error ? error.message : String(error)
3235
+ });
3236
+ } finally {
3237
+ if (!abort.signal.aborted) controller.close();
3238
+ }
3239
+ },
3240
+ cancel() {
3241
+ abort.abort();
3242
+ }
3243
+ });
3244
+ return new Response(stream, { headers: {
3245
+ "content-type": "application/x-ndjson",
3246
+ "cache-control": "no-store"
3247
+ } });
3248
+ });
3249
+ app.get("/:id/diff", async (context) => {
3250
+ const input = readQuerySchemas.diff.parse(context.req.query());
3251
+ const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
3252
+ return context.json(await data.changes(pins));
3253
+ });
3254
+ app.get("/:id/commits", async (context) => {
3255
+ const input = readQuerySchemas.commits.parse(context.req.query());
3256
+ return context.json(await data.commits((await data.resolveSource(readReview(context.req.param("id"), input.version))).pins));
3257
+ });
3258
+ }
3259
+ app.post("/:id/copy-context", async (context) => {
3260
+ const query = readQuerySchemas.get.pick({ version: true }).extend({ mode: coverageModeSchema }).parse(context.req.query());
3261
+ const selection = AgentSelectionSchema.parse(await readBoundedRequestJson(context.req.raw));
3262
+ return context.json({ text: await selectionContext(context.req.param("id"), selection, query.version) });
3263
+ });
3264
+ /** The Markdown an agent gets for a selection, copied or asked about. */
3265
+ async function selectionContext(reviewId, selection, version) {
3266
+ if (selection.apiSource && selection.apiSource.reviewId !== reviewId) throw new ReviewInputError("Selection belongs to another review.");
3267
+ const snapshot = readReview(reviewId, selection.apiSource?.version ?? version);
3268
+ const target = selection.target;
3269
+ let excerpt = "";
3270
+ if (target.kind === "code" && !selection.selectedDiff) {
3271
+ if (!data) throw new ReviewInputError("Source data is unavailable.", 409);
3272
+ const source = await data.quote((await data.resolveSource(snapshot, selection.apiSource?.commit, selection.apiSource?.pins)).pins, {
3273
+ side: target.side,
3274
+ file: target.path,
3275
+ fromLine: target.startLine,
3276
+ toLine: target.endLine
3277
+ });
3278
+ excerpt = `## ${target.side}: ${target.path}:${target.startLine}-${target.endLine} (${source.commit})\n` + source.text.split("\n").map((line) => ` ${line}`).join("\n");
3279
+ }
3280
+ const diff = selection.selectedDiff;
3281
+ const text = selectionMarkdown(selection, excerpt, diff ? {
3282
+ base: `a/${diff.oldPath}`,
3283
+ head: `b/${diff.newPath}`
3284
+ } : void 0);
3285
+ return [
3286
+ `Selected ${target.kind === "text" ? "text" : "code"} from Whiteboard: ${snapshot.title}`,
3287
+ `Session ID: ${snapshot.reviewId}`,
3288
+ `Version: ${snapshot.version}`,
3289
+ ...selection.apiSource?.commit ? [`Selected commit: ${selection.apiSource.commit}`] : [],
3290
+ ...selection.apiSource?.pins ? [
3291
+ `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
3292
+ ...selection.apiSource.pins.base ? [`Selected base: ${selection.apiSource.pins.base}`] : [],
3293
+ `Selected head: ${selection.apiSource.pins.head}`
3294
+ ] : [],
3295
+ ...snapshot.pins ? [
3296
+ `Repository ID: ${snapshot.pins.repositoryId}`,
3297
+ `Session base: ${snapshot.pins.base}`,
3298
+ `Session head: ${snapshot.pins.head}`
3299
+ ] : [],
3300
+ `Read this version with session_get({"sessionId":"${snapshot.reviewId}","version":${snapshot.version},"full":true}).`,
3301
+ "",
3302
+ text,
3303
+ "",
3304
+ ""
3305
+ ].join("\n");
3306
+ }
3307
+ if (ask && data) {
3308
+ const readThread = (reviewId, threadId) => {
3309
+ const thread = ask.threads.get(threadId);
3310
+ if (!thread || thread.reviewId !== reviewId) throw new ReviewInputError("This conversation has ended.", 404);
3311
+ return thread;
3312
+ };
3313
+ /** What an agent reads before a session's first question: the review,
3314
+ * the checkout, and the selection. */
3315
+ const askContext = async (agent, snapshot, checkout, selection, version) => {
3316
+ const reach = await ask.threads.reach(agent);
3317
+ const { reviewId } = snapshot;
3318
+ return [
3319
+ `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about a selection.`,
3320
+ checkout.live ? "Your working directory is the repository the review describes." : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
3321
+ "Answer the question.",
3322
+ "Name files by their path from the checkout root, with a line where it helps, as in `src/app.ts:42`; the reviewer can open them from your answer.",
3323
+ ...reach?.kind === "mcp" ? [`The whiteboard MCP tools read and change this review: its sessionId is "${reviewId}". Read it with session_get. If the reviewer asks you to change the review, edit it with session_edit; do not write files to do it.`] : reach?.kind === "cli" ? [`Whiteboard's CLI reads and changes this review from your shell: its sessionId is "${reviewId}". Read it with \`${reach.command} api session_get '{"sessionId":"${reviewId}"}'\`. If the reviewer asks you to change the review, edit it with \`${reach.command} api session_edit '<json>'\`; do not write files to do it. \`${reach.command} api tools\` lists each tool's input.`] : [],
3324
+ "",
3325
+ await selectionContext(reviewId, selection, version)
3326
+ ].join("\n");
3327
+ };
3328
+ app.get("/:id/ask/agents", async (context) => context.json({ agents: await ask.agents() }));
3329
+ app.get("/:id/ask/agents/:agent/offer", async (context) => {
3330
+ const agent = z.enum(askAgentIds).parse(context.req.param("agent"));
3331
+ const { model } = askOfferQuerySchema.parse(context.req.query());
3332
+ const stored = store.askHistory.offer(agent);
3333
+ const last = stored && Object.keys(stored.choices).length ? stored : void 0;
3334
+ const models = last?.choices.model;
3335
+ const another = model !== void 0 && model !== models?.current && (!models || models.options.some((option) => option.value === model));
3336
+ const known = another ? store.askHistory.offer(agent, model) : last;
3337
+ if (known) return context.json({ offer: known });
3338
+ const checkout = await data.agentCheckout(readReview(context.req.param("id")));
3339
+ const offer = await ask.threads.offered(agent, checkout.rootPath, another ? model : void 0);
3340
+ if (last) store.askHistory.saveModelOffer(agent, offer);
3341
+ else store.askHistory.saveOffer(agent, offer);
3342
+ return context.json({ offer });
3343
+ });
3344
+ app.get("/:id/ask/mentions", async (context) => {
3345
+ const reviewId = context.req.param("id");
3346
+ const { query, thread } = askMentionsSchema.parse(context.req.query());
3347
+ const cwd = thread ? readThread(reviewId, thread).read().cwd : (await data.agentCheckout(readReview(reviewId))).rootPath;
3348
+ const files = fuzzyRank(query, await mentionableFiles(cwd), (file) => [file, file.slice(file.lastIndexOf("/") + 1)]);
3349
+ return context.json({ paths: files.slice(0, MENTION_LIMIT) });
3350
+ });
3351
+ app.post("/:id/ask", async (context) => {
3352
+ const reviewId = context.req.param("id");
3353
+ const { version } = readQuerySchemas.get.pick({ version: true }).parse(context.req.query());
3354
+ const input = askStartSchema.parse(await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES));
3355
+ const snapshot = readReview(reviewId, version);
3356
+ const checkout = await data.agentCheckout(snapshot);
3357
+ const target = input.selection.target;
3358
+ const id = crypto.randomUUID();
3359
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
3360
+ const thread = ask.threads.open({
3361
+ id,
3362
+ reviewId,
3363
+ agent: input.agent,
3364
+ onSession: (sessionId) => store.askHistory.get(id) ? store.askHistory.updateSession(id, sessionId) : store.askHistory.save({
3365
+ id,
3366
+ reviewId,
3367
+ agent: input.agent,
3368
+ sessionId,
3369
+ version: snapshot.version,
3370
+ head: checkout.head,
3371
+ cwd: checkout.rootPath,
3372
+ selection: input.selection,
3373
+ title: input.question.text.slice(0, 200),
3374
+ createdAt,
3375
+ updatedAt: createdAt,
3376
+ bypass: input.bypass
3377
+ }),
3378
+ onTurn: () => store.askHistory.touch(id),
3379
+ onSave: (entries) => store.askHistory.saveEntries(id, entries),
3380
+ picks: input.picks,
3381
+ bypass: input.bypass,
3382
+ onBypass: (bypass) => store.askHistory.setBypass(id, bypass),
3383
+ onOffer: (offer) => store.askHistory.saveOffer(input.agent, offer),
3384
+ onTitle: (title) => store.askHistory.rename(id, title),
3385
+ cwd: checkout.rootPath,
3386
+ head: checkout.head,
3387
+ selection: {
3388
+ title: input.selection.title,
3389
+ quote: target.kind === "text" ? target.quote : void 0
3390
+ },
3391
+ context: await askContext(input.agent, snapshot, checkout, input.selection, version),
3392
+ question: input.question
3393
+ });
3394
+ return context.json({ threadId: thread.id });
3395
+ });
3396
+ app.get("/:id/ask/threads", (context) => {
3397
+ const reviewId = context.req.param("id");
3398
+ readReview(reviewId);
3399
+ return context.json({ threads: store.askHistory.list(reviewId) });
3400
+ });
3401
+ app.post("/:id/ask/:threadId/open", async (context) => {
3402
+ const reviewId = context.req.param("id");
3403
+ const threadId = context.req.param("threadId");
3404
+ if (ask.threads.get(threadId)?.reviewId === reviewId) return context.json({ threadId });
3405
+ const record = store.askHistory.get(threadId);
3406
+ const { picks } = askOpenSchema.parse(await readBoundedRequestJson(context.req.raw, void 0, {}));
3407
+ if (record?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
3408
+ const snapshot = readReview(reviewId, record.version);
3409
+ const checkout = await data.agentCheckout(snapshot);
3410
+ const target = record.selection.target;
3411
+ ask.threads.open({
3412
+ id: record.id,
3413
+ reviewId,
3414
+ agent: record.agent,
3415
+ cwd: checkout.rootPath,
3416
+ head: checkout.head,
3417
+ selection: {
3418
+ title: record.selection.title,
3419
+ quote: target.kind === "text" ? target.quote : void 0
3420
+ },
3421
+ resume: {
3422
+ sessionId: record.sessionId,
3423
+ entries: record.entries
3424
+ },
3425
+ context: await askContext(record.agent, snapshot, checkout, record.selection, record.version),
3426
+ onSession: (sessionId) => store.askHistory.updateSession(record.id, sessionId),
3427
+ onTurn: () => store.askHistory.touch(record.id),
3428
+ onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
3429
+ picks,
3430
+ bypass: record.bypass,
3431
+ onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
3432
+ onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
3433
+ onTitle: (title) => store.askHistory.rename(record.id, title)
3434
+ });
3435
+ return context.json({ threadId: record.id });
3436
+ });
3437
+ app.get("/:id/ask/:threadId/watch", (context) => {
3438
+ return watchAskThread(readThread(context.req.param("id"), context.req.param("threadId")));
3439
+ });
3440
+ app.post("/:id/ask/:threadId/prompt", async (context) => {
3441
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3442
+ const { question } = askFollowUpSchema.parse(await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES));
3443
+ const refusal = thread.askRefusal();
3444
+ if (refusal) throw new ReviewInputError(refusal, 409);
3445
+ thread.ask(question);
3446
+ return context.json({ ok: true });
3447
+ });
3448
+ app.post("/:id/ask/:threadId/permission", async (context) => {
3449
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3450
+ const decision = askDecisionSchema.parse(await readBoundedRequestJson(context.req.raw));
3451
+ if (!thread.decide(decision.permissionId, decision.optionId)) throw new ReviewInputError("This request was already answered.", 409);
3452
+ return context.json({ ok: true });
3453
+ });
3454
+ app.post("/:id/ask/:threadId/files", async (context) => {
3455
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3456
+ const { paths } = askFilesSchema.parse(await readBoundedRequestJson(context.req.raw));
3457
+ const { cwd, entries } = thread.read();
3458
+ const touched = entries.flatMap((entry) => entry.kind === "tool" ? `${entry.title} ${entry.input ?? ""}`.split(/\s+/).flatMap((token) => parseFileRef(token)?.path ?? []) : []);
3459
+ const files = resolveFileRefs(cwd, await checkoutFiles(cwd), paths, touched);
3460
+ return context.json({ files: [...files].map(([path, file]) => ({
3461
+ path,
3462
+ file
3463
+ })) });
3464
+ });
3465
+ app.post("/:id/ask/:threadId/choice", async (context) => {
3466
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3467
+ const { kind, value } = askChoiceSchema.parse(await readBoundedRequestJson(context.req.raw));
3468
+ if (thread.read().status !== "idle") throw new ReviewInputError("Settings can change once the agent finishes answering.", 409);
3469
+ try {
3470
+ await thread.choose(kind, value);
3471
+ } catch (error) {
3472
+ throw new ReviewInputError(errorMessage(error), 409);
3473
+ }
3474
+ return context.json({ ok: true });
3475
+ });
3476
+ app.post("/:id/ask/:threadId/permissions", async (context) => {
3477
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3478
+ const { bypass } = askPermitSchema.parse(await readBoundedRequestJson(context.req.raw));
3479
+ if (thread.read().status !== "idle") throw new ReviewInputError("Settings can change once the agent finishes answering.", 409);
3480
+ try {
3481
+ await thread.permit(bypass);
3482
+ } catch (error) {
3483
+ throw new ReviewInputError(errorMessage(error), 409);
3484
+ }
3485
+ return context.json({ ok: true });
3486
+ });
3487
+ app.post("/:id/ask/:threadId/retry", (context) => {
3488
+ const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3489
+ if (thread.read().status !== "failed") throw new ReviewInputError("Only a conversation that failed can try again.", 409);
3490
+ thread.retry();
3491
+ return context.json({ ok: true });
3492
+ });
3493
+ app.post("/:id/ask/:threadId/cancel", async (context) => {
3494
+ await readThread(context.req.param("id"), context.req.param("threadId")).cancel();
3495
+ return context.json({ ok: true });
3496
+ });
3497
+ app.post("/:id/ask/:threadId/close", (context) => {
3498
+ readThread(context.req.param("id"), context.req.param("threadId"));
3499
+ ask.threads.close(context.req.param("threadId"));
3500
+ return context.json({ ok: true });
3501
+ });
3502
+ app.delete("/:id/ask/:threadId", (context) => {
3503
+ const reviewId = context.req.param("id");
3504
+ const threadId = context.req.param("threadId");
3505
+ if (store.askHistory.get(threadId)?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
3506
+ if (ask.threads.get(threadId)?.reviewId === reviewId) ask.threads.close(threadId);
3507
+ store.askHistory.delete(threadId);
3508
+ return context.json({ ok: true });
3509
+ });
3510
+ }
3511
+ app.get("/:id/stack", async (context) => {
3512
+ const query = readQuerySchemas.get.parse(context.req.query());
3513
+ const id = context.req.param("id");
3514
+ const snapshot = readReview(id, query.version);
3515
+ if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
3516
+ const layers = await resolveReviewStackLayers(snapshot, store.list());
3517
+ return context.json({ layers });
3518
+ });
3519
+ app.get("/:id/history", (context) => {
3520
+ const id = context.req.param("id");
3521
+ if (!isShared(id)) return context.json(store.history(id));
3522
+ const snapshot = readReview(id);
3523
+ return context.json([{
3524
+ version: snapshot.version,
3525
+ title: snapshot.title,
3526
+ createdAt: snapshot.createdAt
3527
+ }]);
3528
+ });
3529
+ app.get("/:id/inspect", (context) => {
3530
+ const query = inspectQuerySchema.parse(context.req.query());
3531
+ const id = context.req.param("id");
3532
+ const snapshot = readReview(id, query.version);
3533
+ return context.json(query.format === "text" ? documentText(snapshot, query.targetId, Boolean(query.full)) : query.targetId !== void 0 ? inspectSnapshot(snapshot, query.targetId) : query.full ? snapshot : inspectSnapshot(snapshot));
3534
+ });
3535
+ app.get("/:id", async (context) => {
3536
+ const query = readQuerySchemas.get.parse(context.req.query());
3537
+ const snapshot = { ...readReview(context.req.param("id"), query.version) };
3538
+ if (data && query.full) try {
3539
+ const pins = await data.sourcePins(snapshot);
3540
+ if (pins) snapshot.pins = pins;
3541
+ } catch (error) {
3542
+ if (!(error instanceof ReviewInputError) || error.status !== 404) throw error;
3543
+ snapshot.sourceUnavailable = true;
3544
+ }
3545
+ return context.json(query.full ? snapshot : inspectSnapshot(snapshot, query.targetId));
3546
+ });
3547
+ /** After a lens write: the changed lines still uncategorized at that
3548
+ * version, so the author can fill the gaps. A comparison that cannot be
3549
+ * read leaves a warning instead of failing the saved write. */
3550
+ const lensGaps = async (reviewId, version, request) => {
3551
+ if (!data) return {};
3552
+ try {
3553
+ return { uncategorized: uncategorizedReport(await reviewProgress(store, data, store.read(reviewId, version), request.signal)) };
3554
+ } catch (error) {
3555
+ return { warnings: [`Uncategorized changes are unavailable: ${errorMessage(error)}`] };
3556
+ }
3557
+ };
3558
+ app.post("/commands", async (context) => {
3559
+ const { command: body, open: requestedOpen } = takeCreateOpen(await readBoundedRequestJson(context.req.raw));
3560
+ const request = await locateRepositories(body, (path) => {
3561
+ if (!data) throw new ReviewInputError("Repositories are unavailable.");
3562
+ return data.register(path);
3563
+ });
3564
+ const input = commandSchema.parse(request);
3565
+ const command = sharedCommandSchema.safeParse(input);
3566
+ if (command.success) {
3567
+ refuseDisabledScratchpad(command.data.operation.reviewId);
3568
+ await ensureScratchpad(command.data.operation.reviewId);
3569
+ }
3570
+ if (input.operation.type === "create" && input.operation.kind === "scratchpad") refuseDisabledScratchpad(SCRATCHPAD_ID);
3571
+ if (command.success && command.data.operation.reviewId?.startsWith("shared-")) {
3572
+ const parsed = commandSchema.parse(input);
3573
+ if (shared && parsed.operation.type === "delete") {
3574
+ await shared.removeLocal(parsed.operation.reviewId);
3575
+ return context.json({
3576
+ reviewId: parsed.operation.reviewId,
3577
+ version: 0,
3578
+ deleted: true
3579
+ });
3580
+ }
3581
+ if (shared && parsed.operation.type === "attention") {
3582
+ await shared.setAttention(parsed.operation.reviewId, parsed.operation.action);
3583
+ return context.json({
3584
+ reviewId: parsed.operation.reviewId,
3585
+ version: shared.get(parsed.operation.reviewId).snapshot.version,
3586
+ attention: true
3587
+ });
3588
+ }
3589
+ throw new ReviewInputError("Shared reviews are read-only.", 409);
3590
+ }
3591
+ const result = await store.execute(input);
3592
+ if (input.operation.type === "edit" && data && !result.deleted) {
3593
+ const { quotes, unquoted } = await anchorQuotes(result.version > 0 ? store.read(result.reviewId, result.version - 1) : void 0, store.read(result.reviewId, result.version), async (pins, side, file) => (await data.file(pins, side, file)).text);
3594
+ return context.json({
3595
+ ...result,
3596
+ ...quotes.length && { quotes },
3597
+ ...unquoted && { unquotedAnchors: unquoted }
3598
+ });
3599
+ }
3600
+ if (input.operation.type === "lens_edit") {
3601
+ const gaps = await lensGaps(result.reviewId, result.version, context.req.raw);
3602
+ return context.json({
3603
+ ...result,
3604
+ ...gaps,
3605
+ ...gaps.warnings && { warnings: [...result.warnings ?? [], ...gaps.warnings] }
3606
+ });
3607
+ }
3608
+ if (input.operation.type !== "create") return context.json(result);
3609
+ if (result.created !== false) hooks.onReviewCreated?.({
3610
+ reviewId: result.reviewId,
3611
+ kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
3612
+ blocks: store.read(result.reviewId).document.length,
3613
+ ...reviewRequestOrigin(context.req.raw.headers)
3614
+ });
3615
+ return context.json({
3616
+ ...result,
3617
+ review: store.summary(result.reviewId),
3618
+ ...requestedOpen === false ? { opened: false } : await openCreated(result.reviewId)
3619
+ });
3620
+ });
3621
+ return app;
3622
+ }
3623
+ const systemErrorSchema = z.object({ code: z.string().regex(/^[A-Z0-9_]+$/) });
3624
+ /** A system error code such as EACCES, else the error's class; never its message. */
3625
+ function failureKind(error) {
3626
+ const system = systemErrorSchema.safeParse(error);
3627
+ return system.success ? system.data.code : error.name;
3628
+ }
3629
+ /**
3630
+ * `open` steers presentation, not the saved review, so it stays out of the
3631
+ * command. Anything else, including `open` off create, is left for
3632
+ * commandSchema to reject.
3633
+ */
3634
+ function takeCreateOpen(body) {
3635
+ const create = z.looseObject({ operation: z.looseObject({
3636
+ type: z.literal("create"),
3637
+ open: z.boolean().optional()
3638
+ }) }).safeParse(body);
3639
+ if (!create.success) return { command: body };
3640
+ const { open, ...operation } = create.data.operation;
3641
+ return {
3642
+ command: {
3643
+ ...create.data,
3644
+ operation
3645
+ },
3646
+ open
3647
+ };
3648
+ }
3649
+ /**
3650
+ * Agents name a checkout by its path (pathTargetSchema, and repositoryPath on
3651
+ * a create from a PR); the store keeps the id it registers as. A command that
3652
+ * already names ids is left as it is.
3653
+ */
3654
+ async function locateRepositories(body, register) {
3655
+ const located = z.looseObject({ operation: z.looseObject({
3656
+ type: z.enum(["create", "set_target"]),
3657
+ target: z.looseObject({ repositoryPath: z.string() }).optional(),
3658
+ repositoryPath: z.string().optional()
3659
+ }) }).safeParse(body);
3660
+ if (!located.success) return body;
3661
+ const { target, repositoryPath, ...operation } = located.data.operation;
3662
+ const byId = async ({ repositoryPath: path, ...rest }) => ({
3663
+ ...rest,
3664
+ repositoryId: (await register(path)).id
3665
+ });
3666
+ return {
3667
+ ...located.data,
3668
+ operation: {
3669
+ ...operation,
3670
+ ...target && { target: await byId(target) },
3671
+ ...repositoryPath && await byId({ repositoryPath })
3672
+ }
3673
+ };
3674
+ }
3675
+ /** Send committed state, coalescing updates when the reader falls behind. */
3676
+ function watch$1(read, subscribe, probe = read) {
3677
+ probe();
3678
+ let stop = () => {};
3679
+ let dirty = true;
3680
+ const encoder = new TextEncoder();
3681
+ const send = (controller) => {
3682
+ if (!dirty || controller.desiredSize === null || controller.desiredSize <= 0) return;
3683
+ try {
3684
+ const line = JSON.stringify(read()) + "\n";
3685
+ dirty = false;
3686
+ controller.enqueue(encoder.encode(line));
3687
+ } catch (error) {
3688
+ stop();
3689
+ controller.error(error);
3690
+ }
3691
+ };
3692
+ const body = new ReadableStream({
3693
+ start(controller) {
3694
+ stop = subscribe(() => {
3695
+ dirty = true;
3696
+ send(controller);
3697
+ });
3698
+ send(controller);
3699
+ },
3700
+ pull: send,
3701
+ cancel() {
3702
+ stop();
3703
+ }
3704
+ });
3705
+ return new Response(body, { headers: {
3706
+ "content-type": "application/x-ndjson",
3707
+ "cache-control": "no-store"
3708
+ } });
3709
+ }
3710
+ //#endregion
3711
+ //#region src/server/review-server-core.ts
3712
+ const version = readReviewPackageVersion(import.meta.url);
3713
+ const commit = readBuildCommit(import.meta.url);
3714
+ /**
3715
+ * What every review server shares: CORS, an open /health, token auth, and
3716
+ * the /control relay a Desktop attaches to, with errors answered as JSON.
3717
+ * Callers add their routes after.
3718
+ */
3719
+ function createReviewServerApp(input) {
3720
+ const app = new Hono();
3721
+ app.use("*", async (context, next) => {
3722
+ await next();
3723
+ applyCorsHeaders(context.req.raw, context.res);
3724
+ });
3725
+ app.options("*", (context) => corsPreflightResponse(context.req.raw));
3726
+ app.get("/health", (context) => {
3727
+ const health = {
3728
+ ok: true,
3729
+ instanceId: input.instanceId,
3730
+ desktopAttached: input.relay.attached,
3731
+ version
3732
+ };
3733
+ return serverJson(200, isAuthorizedRequest(context.req.raw, input.token) ? {
3734
+ ...health,
3735
+ serverId: input.serverId,
3736
+ serverPid: process.pid,
3737
+ commit
3738
+ } : health);
3739
+ });
3740
+ app.use("*", async (context, next) => {
3741
+ if (!isAuthorizedRequest(context.req.raw, input.token)) return serverJson(401, {
3742
+ ok: false,
3743
+ error: "Unauthorized"
3744
+ });
3745
+ await next();
3746
+ });
3747
+ app.get("/control", (context) => openControlEvents(context, input.relay));
3748
+ app.post("/control/result", async (context) => {
3749
+ const accepted = input.relay.acceptResult(await readBoundedRequestJson(context.req.raw));
3750
+ return serverJson(accepted ? 200 : 404, { ok: accepted });
3751
+ });
3752
+ app.notFound(() => serverJson(404, {
3753
+ ok: false,
3754
+ error: "Not found."
3755
+ }));
3756
+ app.onError((error) => {
3757
+ const serverError = error instanceof ReviewServerError ? error : void 0;
3758
+ const message = toError$1(error).message;
3759
+ return serverJson(serverError?.statusCode ?? httpJsonStatus(error), serverError?.code ? {
3760
+ ok: false,
3761
+ code: serverError.code,
3762
+ error: message
3763
+ } : {
3764
+ ok: false,
3765
+ error: message
3766
+ });
3767
+ });
3768
+ return app;
3769
+ }
3770
+ function createWhiteboardCore(input) {
3771
+ const { store, data, shared } = input.profile;
3772
+ const app = createReviewServerApp({
3773
+ token: input.token,
3774
+ instanceId: input.instanceId,
3775
+ serverId: store.serverId(),
3776
+ relay: input.relay
3777
+ });
3778
+ const callbacks = relayReviewCallbacks(input.relay, input.softwareMapEnabled);
3779
+ const askThreads = input.ask && new AskThreads(launchAskAgent, input.ask.tools);
3780
+ const api = createReviewApi(store, data, callbacks.open, shared, callbacks.capabilities, input.scratchpad, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
3781
+ threads: askThreads,
3782
+ agents: () => detectAskAgents()
3783
+ });
3784
+ if (!shared) mountSharingPublisher(api, store, data);
3785
+ return {
3786
+ app,
3787
+ api,
3788
+ close: () => askThreads?.closeAll()
3789
+ };
3790
+ }
3791
+ /** The Desktop callbacks `createReviewApi` takes, answered over the relay. */
3792
+ function relayReviewCallbacks(relay, softwareMapEnabled = false) {
3793
+ return {
3794
+ async open(review) {
3795
+ const result = await relay.dispatch({
3796
+ name: "openApiReview",
3797
+ args: review
3798
+ });
3799
+ if (!result.ok) throw new ReviewInputError(result.error, 409);
3800
+ return z.object({ softwareMapEnabled: z.boolean() }).parse(result.result);
3801
+ },
3802
+ async capabilities() {
3803
+ if (!relay.attached) return {
3804
+ desktopAvailable: false,
3805
+ softwareMapEnabled
3806
+ };
3807
+ const result = await relay.dispatch({
3808
+ name: "authoringCapabilities",
3809
+ args: {}
3810
+ });
3811
+ if (!result.ok) throw new ReviewInputError(result.error, 409);
3812
+ return {
3813
+ desktopAvailable: true,
3814
+ ...z.object({ softwareMapEnabled: z.boolean() }).parse(result.result)
3815
+ };
3816
+ }
3817
+ };
3818
+ }
3819
+ function openControlEvents(context, relay) {
3820
+ let attached = false;
3821
+ const response = streamSSE(context, async (output) => {
3822
+ let finish;
3823
+ const disconnected = new Promise((resolve) => {
3824
+ finish = resolve;
3825
+ });
3826
+ const abort = new AbortController();
3827
+ let pending = output.write(": attached\n\n").then(() => void 0);
3828
+ const writer = {
3829
+ signal: abort.signal,
3830
+ write(frame) {
3831
+ pending = pending.then(async () => {
3832
+ await output.write(frame);
3833
+ });
3834
+ },
3835
+ close() {
3836
+ finish();
3837
+ output.close();
3838
+ }
3839
+ };
3840
+ output.onAbort(() => {
3841
+ abort.abort();
3842
+ finish();
3843
+ });
3844
+ attached = relay.attach(writer);
3845
+ if (!attached) {
3846
+ finish();
3847
+ return;
3848
+ }
3849
+ try {
3850
+ await disconnected;
3851
+ await pending;
3852
+ } finally {
3853
+ abort.abort();
3854
+ }
3855
+ });
3856
+ if (!attached) {
3857
+ response.body?.cancel();
3858
+ return serverJson(409, {
3859
+ ok: false,
3860
+ error: "This server has no room for another Whiteboard Desktop."
3861
+ });
3862
+ }
3863
+ response.headers.set("connection", "close");
3864
+ response.headers.set("cache-control", "no-cache, no-transform");
3865
+ response.headers.set("content-type", "text/event-stream; charset=utf-8");
3866
+ return response;
3867
+ }
3868
+ function serverJson(status, body) {
3869
+ return jsonResponse(body, status, { cacheControl: "no-store" });
3870
+ }
3871
+ function httpJsonStatus(cause) {
3872
+ return cause instanceof HttpJsonError ? cause.statusCode : 400;
3873
+ }
3874
+ function toError$1(cause) {
3875
+ return cause instanceof Error ? cause : new Error(String(cause));
3876
+ }
3877
+ //#endregion
3878
+ //#region src/server/process-error-telemetry.ts
3879
+ function serverCrashReportPath(env = process.env) {
3880
+ return path.join(devReviewHome(env), "telemetry", "server-crash.json");
3881
+ }
3882
+ function installProcessErrorTelemetry(telemetry, options = {}) {
3883
+ const target = options.process ?? process;
3884
+ const crashReportPath = options.crashReportPath ?? serverCrashReportPath();
3885
+ const onUncaught = (cause) => {
3886
+ const properties = serverErrorProperties(toError(cause), options.appSessionId);
3887
+ if (target.listenerCount("uncaughtException") === 0) writeCrashReport(crashReportPath, properties);
3888
+ else sendServerError(telemetry, properties);
3889
+ };
3890
+ const onRejection = (cause) => {
3891
+ sendServerError(telemetry, serverErrorProperties(toError(cause), options.appSessionId));
3892
+ };
3893
+ const watchRejections = target.listenerCount("unhandledRejection") > 0;
3894
+ target.on("uncaughtExceptionMonitor", onUncaught);
3895
+ if (watchRejections) target.on("unhandledRejection", onRejection);
3896
+ return () => {
3897
+ target.off("uncaughtExceptionMonitor", onUncaught);
3898
+ if (watchRejections) target.off("unhandledRejection", onRejection);
3899
+ };
3900
+ }
3901
+ /** Sends, once, the fatal error an earlier server process left behind. */
3902
+ async function drainServerCrashReport(telemetry, crashReportPath = serverCrashReportPath()) {
3903
+ let properties;
3904
+ try {
3905
+ const text = await readFile(crashReportPath, "utf8");
3906
+ await rm(crashReportPath, { force: true });
3907
+ properties = parseJsonText(text);
3908
+ } catch {
3909
+ return;
3910
+ }
3911
+ await sendServerError(telemetry, properties);
3912
+ }
3913
+ function toError(cause) {
3914
+ return cause instanceof Error ? cause : new Error(String(cause));
3915
+ }
3916
+ function serverErrorProperties(error, appSessionId) {
3917
+ const properties = {
3918
+ error_source: "server_unexpected",
3919
+ error_process: "server",
3920
+ error_name: error.name
3921
+ };
3922
+ if (appSessionId) properties.app_session_id = appSessionId;
3923
+ return mergeErrorTelemetryProperties(properties, {
3924
+ name: error.name,
3925
+ message: error.message,
3926
+ stack: error.stack ?? ""
3927
+ });
3928
+ }
3929
+ function writeCrashReport(crashReportPath, properties) {
3930
+ const sanitized = sanitizeUiTelemetryEvent({
3931
+ name: "client_error",
3932
+ properties
3933
+ });
3934
+ if (!sanitized) return;
3935
+ try {
3936
+ mkdirSync(path.dirname(crashReportPath), { recursive: true });
3937
+ writeFileSync(crashReportPath, JSON.stringify(sanitized.properties), { mode: 384 });
3938
+ } catch {}
3939
+ }
3940
+ async function sendServerError(telemetry, properties) {
3941
+ const sanitized = sanitizeUiTelemetryEvent({
3942
+ name: "client_error",
3943
+ properties
3944
+ });
3945
+ const admitted = sanitized ? admitUiTelemetryEvent(sanitized) : void 0;
3946
+ if (!admitted) return;
3947
+ try {
3948
+ await telemetry.captureUiEvent?.(admitted.event, admitted.properties);
3949
+ } catch {}
3950
+ }
3951
+ //#endregion
3952
+ export { serverJson as a, recordClientError as c, HttpJsonError as d, ReviewServerError as f, createWhiteboardCore as i, createNodeRequestListener as l, installProcessErrorTelemetry as n, captureSanitizedUiTelemetry as o, GlobalReviewDesktopVerbRelay as p, serverCrashReportPath as r, clientErrorsForSession as s, drainServerCrashReport as t, readBoundedRequestJson as u };