@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.116

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 (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +8 -13
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
@@ -1,32 +1,94 @@
1
1
  #!/usr/bin/env node
2
- import { C as ReviewInputError, a as diffFileSummaries, b as resolveRevision, i as diff, x as splitGitPatchFiles } from "../src-DiHP11l3.js";
2
+ import { S as splitGitPatchFiles, a as diff, o as diffFileSummaries } from "../src-BMPuxWv4.js";
3
3
  import { c as jsonString, i as jsonBoolean, n as isJsonObject, p as isObjectValue, s as jsonProperty, u as parseJsonText } from "../src-X9phtB2j.js";
4
- import { i as SharedReviewStore } from "../client-DzcSt6pV.js";
5
- import { c as ReviewBugReportRequestSchema, l as parseReviewBugReportResponse, o as parseZod, s as ReviewBugReportMetaV2Schema, t as parseReviewCliInstallApplyRequest, v as reviewDiffrSummarizerInputSchema } from "../src-WCbabvdS.js";
6
- import { et as shellQuote, i as reviewInstanceDiscoveryPath, o as reviewLegacyDiscoveryPath } from "../review-home-paths-Y9mYWkCI.js";
7
- import { C as resourceReferences, M as lensSchema, p as documentSchema } from "../stored-document-migration-u4L_1VZa.js";
8
- import { A as devReviewHome, P as writePrivateJsonAtomic } from "../s3-config-CAxhO9u_.js";
9
- import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-B9PDoAl6.js";
10
- import { c as reviewInstanceIdentity, t as REVIEW_INSTANCE_ENV } from "../desktop-discovery-Ba3dD5Cn.js";
11
- import { t as openReviewProfile } from "../profile-tbGWuWHu.js";
12
- import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-CmM6tCbj.js";
13
- import { o as REVIEW_TELEMETRY_TABS } from "../request-origin-DA4K1qWY.js";
14
- import { c as removeLegacyReviewSkills, d as skipCliInstall, i as finishCliInstallUpdate, l as resetCliInstall, n as applyCliInstall, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-D0hF0sKk.js";
15
- import { _ as setDiffrConfigValue, a as serverJson, c as recordClientError, d as HttpJsonError, f as ReviewServerError, g as saveDiffrSummarizer, h as readDiffrConfig, i as createWhiteboardCore, l as createNodeRequestListener, m as migrateDiffrConfig, n as installProcessErrorTelemetry, o as captureSanitizedUiTelemetry, p as GlobalReviewDesktopVerbRelay, r as serverCrashReportPath, s as clientErrorsForSession, t as drainServerCrashReport, u as readBoundedRequestJson, v as testDiffrSummarizer } from "../process-error-telemetry-BCBYp2t4.js";
16
- import { c as readQuerySchemas } from "../authoring-tools-fdA0RPIT.js";
17
- import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
4
+ import { d as StreamLimitError, f as readBoundedStream, i as SharedReviewStore } from "../client-DhZzPePs.js";
5
+ import { A as reviewDiffrSummarizerInputSchema, E as ReviewGatewayHostSchema, T as ReviewConnectRequestSchema, c as ReviewBugReportMetaV2Schema, d as REVIEW_CLIENT_HEADER, f as REVIEW_CLIENT_REMOTE, g as REVIEW_HOST_HEADER, l as ReviewBugReportRequestSchema, r as parseReviewDesktopVerbFrame, s as parseZod, t as parseReviewCliInstallApplyRequest, u as parseReviewBugReportResponse } from "../src-Bgvf1sEN.js";
6
+ import { i as reviewInstanceDiscoveryPath, o as reviewLegacyDiscoveryPath } from "../review-home-paths-zwevIq4I.js";
7
+ import { C as resourceReferences } from "../stored-document-migration-CFOvjRwW.js";
8
+ import { t as ReviewInputError } from "../input-error-OLgB_h31.js";
9
+ import { I as writePrivateJsonAtomic, M as devReviewHome } from "../s3-config-DDWWMh5x.js";
10
+ import { t as errorMessage } from "../error-message-OtiDonty.js";
11
+ import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-BsuI4WUf.js";
12
+ import { _ as reviewInstanceIdentity, u as REVIEW_INSTANCE_ENV } from "../server-discovery-CifQ3yQS.js";
13
+ import { t as openReviewProfile } from "../profile-pAzg7fxC.js";
14
+ import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-BqB4CiTD.js";
15
+ import { o as REVIEW_TELEMETRY_TABS } from "../request-origin-DaRIV3cB.js";
16
+ import { C as saveDiffrSummarizer, D as findExecutable, E as cliAskTools, S as readDiffrConfig, T as testDiffrSummarizer, _ as DEFAULT_MAX_REQUEST_BYTES, a as drainServerCrashReport, b as GlobalReviewDesktopVerbRelay, c as answerErrorsAsJson, d as coverageModeSchema, f as captureSanitizedUiTelemetry, g as readBoundedRequestJson, h as createNodeRequestListener, l as createWhiteboardCore, m as recordClientError, o as installProcessErrorTelemetry, p as clientErrorsForSession, r as takeOverServerHome, s as serverCrashReportPath, u as serverJson, v as HttpJsonError, w as setDiffrConfigValue, x as migrateDiffrConfig, y as ReviewServerError } from "../headless-host-CVXTSIlx.js";
17
+ import { c as removeLegacyReviewSkills, d as skipCliInstall, i as finishCliInstallUpdate, l as resetCliInstall, m as ALL_INSTALL_TARGETS, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus, v as reviewMcpLaunch, y as cursorInstallDeeplink } from "../account-alias-ChZR3RIU.js";
18
+ import { c as readQuerySchemas } from "../authoring-tools-BXKEwhfi.js";
19
+ import crypto, { createHash, randomBytes } from "node:crypto";
18
20
  import { z } from "zod";
19
- import fs, { existsSync } from "node:fs";
20
- import { chmod, copyFile, cp, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
21
+ import fs, { existsSync, readFileSync } from "node:fs";
22
+ import { chmod, copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
21
23
  import path from "node:path";
24
+ import os from "node:os";
25
+ import { execFile } from "node:child_process";
22
26
  import { promisify } from "node:util";
23
27
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
24
28
  import { pathToFileURL } from "node:url";
25
- import { Writable } from "node:stream";
29
+ import { Readable, Writable } from "node:stream";
26
30
  import { pipeline } from "node:stream/promises";
27
31
  import { gzip, gzipSync } from "node:zlib";
28
32
  import { createServer } from "node:http";
29
33
  import { Hono } from "hono";
34
+ import { Agent, request } from "undici";
35
+ //#region ../review-protocol/src/review-host-status.ts
36
+ const OFFLINE = {
37
+ status: "offline",
38
+ label: (alias) => `${alias} offline`,
39
+ action: "retry"
40
+ };
41
+ const UNUSABLE = {
42
+ status: "can't be used",
43
+ label: (alias) => `${alias} can't be used`,
44
+ action: "settings"
45
+ };
46
+ const STATUS = {
47
+ online: {
48
+ status: "online",
49
+ label: (alias) => alias
50
+ },
51
+ connecting: {
52
+ status: "connecting",
53
+ label: (alias) => `Connecting to ${alias}…`,
54
+ busy: true
55
+ },
56
+ installing: {
57
+ status: "installing",
58
+ label: (alias) => `Installing on ${alias}…`,
59
+ busy: true
60
+ },
61
+ offline: OFFLINE,
62
+ unreachable: OFFLINE,
63
+ incompatible: {
64
+ status: "needs an update",
65
+ label: (alias) => `${alias} needs an update`,
66
+ action: "install"
67
+ },
68
+ "not-installed": {
69
+ status: "not installed",
70
+ label: (alias) => `Whiteboard not installed on ${alias}`,
71
+ action: "install"
72
+ },
73
+ "auth-failed": {
74
+ status: "can't sign in",
75
+ label: (alias) => `Can't sign in to ${alias}`,
76
+ action: "settings"
77
+ },
78
+ unsupported: UNUSABLE,
79
+ duplicate: UNUSABLE
80
+ };
81
+ /** One wording and one next step per host state, shared by every surface. */
82
+ function reviewHostStatus(host) {
83
+ const { label, ...rest } = STATUS[host.state];
84
+ const words = label(host.alias);
85
+ return {
86
+ ...rest,
87
+ label: words,
88
+ sentence: host.detail ?? (words.endsWith("…") ? words : `${words}.`)
89
+ };
90
+ }
91
+ //#endregion
30
92
  //#region src/review-bundled-tools.ts
31
93
  function reviewToolsRoot(env = process.env) {
32
94
  return path.join(devReviewHome(env), "review-tools");
@@ -164,7 +226,11 @@ async function hashFile(filePath) {
164
226
  }
165
227
  //#endregion
166
228
  //#region src/server/desktop-host-shutdown.ts
167
- function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySetting, onStageRustAnalyzer) {
229
+ function postToDesktop(hostProcess, message) {
230
+ if (hostProcess.parentPort?.postMessage) hostProcess.parentPort.postMessage(message);
231
+ else hostProcess.send?.(message);
232
+ }
233
+ function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySetting, onStageRustAnalyzer, onRemoteHosts) {
168
234
  const handleMessage = (message) => {
169
235
  if (!isJsonObject(message)) return;
170
236
  const type = jsonProperty(message, "type");
@@ -180,6 +246,11 @@ function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySettin
180
246
  if (type === "stage-rust-analyzer") {
181
247
  const path = jsonString(jsonProperty(message, "path"));
182
248
  if (path) onStageRustAnalyzer?.(path);
249
+ return;
250
+ }
251
+ if (type === "remote-hosts") {
252
+ const hosts = ReviewGatewayHostSchema.array().safeParse(jsonProperty(message, "hosts"));
253
+ if (hosts.success) onRemoteHosts?.(hosts.data);
183
254
  }
184
255
  };
185
256
  if (hostProcess.parentPort) {
@@ -193,6 +264,332 @@ function listenForDesktopHostShutdown(hostProcess, onShutdown, onTelemetrySettin
193
264
  return () => hostProcess.off("message", handleMessage);
194
265
  }
195
266
  //#endregion
267
+ //#region src/agent-connect.ts
268
+ const MARKETPLACE = "devdotfast/whiteboard";
269
+ const PLUGIN = "whiteboard@devfast";
270
+ const STEP_TIMEOUT_MS = 12e4;
271
+ const HARNESSES = {
272
+ claude: {
273
+ name: "Claude Code",
274
+ commands: ["claude"],
275
+ installDirs: [".claude/local", ".local/bin"]
276
+ },
277
+ codex: {
278
+ name: "Codex",
279
+ commands: ["codex"]
280
+ },
281
+ cursor: {
282
+ name: "Cursor",
283
+ commands: ["cursor"],
284
+ homeDir: ".cursor",
285
+ apps: ["/Applications/Cursor.app"]
286
+ },
287
+ opencode: {
288
+ name: "OpenCode",
289
+ commands: ["opencode"],
290
+ installDirs: [".opencode/bin"]
291
+ },
292
+ pi: {
293
+ name: "Pi",
294
+ commands: ["pi"]
295
+ },
296
+ omp: {
297
+ name: "oh-my-pi",
298
+ commands: ["omp"],
299
+ homeDir: ".omp"
300
+ },
301
+ copilot: {
302
+ name: "Copilot CLI",
303
+ commands: ["copilot"]
304
+ }
305
+ };
306
+ const runProcess = (file, args) => new Promise((resolve) => {
307
+ execFile(file, args, {
308
+ timeout: STEP_TIMEOUT_MS,
309
+ maxBuffer: 4194304,
310
+ env: {
311
+ ...process.env,
312
+ CI: "1",
313
+ GIT_TERMINAL_PROMPT: "0"
314
+ },
315
+ windowsHide: true
316
+ }, (error, stdout, stderr) => {
317
+ const output = `${stdout}${stderr}`.trim();
318
+ resolve(error ? {
319
+ code: 1,
320
+ output: output || error.message
321
+ } : {
322
+ code: 0,
323
+ output
324
+ });
325
+ });
326
+ });
327
+ async function exists(target) {
328
+ try {
329
+ await stat(target);
330
+ return true;
331
+ } catch {
332
+ return false;
333
+ }
334
+ }
335
+ async function findHarness(harness, homeDir, env) {
336
+ const installed = (harness.installDirs ?? []).map((directory) => path.join(homeDir, directory));
337
+ for (const command of harness.commands) {
338
+ const found = await findExecutable(command, env) ?? await findExecutable(command, { PATH: installed.join(path.delimiter) });
339
+ if (found) return found;
340
+ }
341
+ }
342
+ async function harnessFound(harness, homeDir, env, platform) {
343
+ if (await findHarness(harness, homeDir, env)) return true;
344
+ if (harness.homeDir && await exists(path.join(homeDir, harness.homeDir))) return true;
345
+ if (platform !== "darwin") return false;
346
+ for (const app of harness.apps ?? []) if (await exists(app)) return true;
347
+ return false;
348
+ }
349
+ async function readJson(file) {
350
+ try {
351
+ return JSON.parse(await readFile(file, "utf8"));
352
+ } catch {
353
+ return;
354
+ }
355
+ }
356
+ /** Follows `keys` down nested JSON objects. */
357
+ function lookup(value, ...keys) {
358
+ let current = value;
359
+ for (const key of keys) {
360
+ if (!isJsonObject(current)) return void 0;
361
+ current = current[key];
362
+ }
363
+ return current;
364
+ }
365
+ /** Whether a `[plugins."…"]` table names the plugin, and does not turn it
366
+ * off. */
367
+ function codexHasPlugin(toml) {
368
+ const lines = toml.split("\n");
369
+ const start = lines.findIndex((line) => line.trim() === `[plugins."${PLUGIN}"]`);
370
+ if (start === -1) return false;
371
+ for (const line of lines.slice(start + 1)) {
372
+ const trimmed = line.trim();
373
+ if (trimmed.startsWith("[")) break;
374
+ if (/^enabled\s*=\s*false\b/.test(trimmed)) return false;
375
+ }
376
+ return true;
377
+ }
378
+ /** Whether a harness already has Whiteboard, as its own config records it:
379
+ * the plugin it installed, or the MCP server added to it. Copilot keeps no
380
+ * record Whiteboard reads, so it always installs again, which is harmless. */
381
+ async function harnessConnected(target, homeDir, env, platform) {
382
+ const claudeDir = env.CLAUDE_CONFIG_DIR ?? path.join(homeDir, ".claude");
383
+ const hasServer = async (file, ...keys) => lookup(await readJson(file), ...keys, "whiteboard") !== void 0;
384
+ switch (target) {
385
+ case "claude": {
386
+ if (platform === "win32") return hasServer(path.join(homeDir, ".claude.json"), "mcpServers");
387
+ const [installed, settings] = await Promise.all([readJson(path.join(claudeDir, "plugins", "installed_plugins.json")), readJson(path.join(claudeDir, "settings.json"))]);
388
+ const installs = lookup(installed, "plugins", PLUGIN);
389
+ return Array.isArray(installs) && installs.length > 0 && lookup(settings, "enabledPlugins", PLUGIN) !== false;
390
+ }
391
+ case "codex": try {
392
+ return codexHasPlugin(await readFile(path.join(env.CODEX_HOME ?? path.join(homeDir, ".codex"), "config.toml"), "utf8"));
393
+ } catch {
394
+ return false;
395
+ }
396
+ case "opencode": {
397
+ const config = await readJson(path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "opencode", "opencode.json"));
398
+ return lookup(config, "mcp", "whiteboard") !== void 0 || lookup(config, "mcp", "servers", "whiteboard") !== void 0;
399
+ }
400
+ case "pi": return hasServer(path.join(homeDir, ".pi", "agent", "mcp.json"), "mcpServers");
401
+ case "omp": return hasServer(path.join(homeDir, ".omp", "agent", "mcp.json"), "mcpServers");
402
+ case "cursor": return cursorHasServer(homeDir);
403
+ case "copilot": return false;
404
+ }
405
+ }
406
+ /** Every harness Whiteboard connects to, whether this machine has it, and
407
+ * whether it already has Whiteboard. */
408
+ async function detectConnectAgents(input = {}) {
409
+ const homeDir = input.homeDir ?? os.homedir();
410
+ const env = input.env ?? process.env;
411
+ const platform = input.platform ?? process.platform;
412
+ return Promise.all(ALL_INSTALL_TARGETS.map(async (target) => {
413
+ const found = await harnessFound(HARNESSES[target], homeDir, env, platform);
414
+ return {
415
+ target,
416
+ name: HARNESSES[target].name,
417
+ found,
418
+ connected: found && await harnessConnected(target, homeDir, env, platform)
419
+ };
420
+ }));
421
+ }
422
+ const ALREADY = /already/i;
423
+ /** The commands that install Whiteboard into a CLI harness, as
424
+ * connect-prompts.ts tells an agent to run them. */
425
+ function cliSteps(target, launch, platform) {
426
+ const marketplace = {
427
+ args: [
428
+ "plugin",
429
+ "marketplace",
430
+ "add",
431
+ MARKETPLACE
432
+ ],
433
+ optional: true
434
+ };
435
+ const windowsServer = (scope) => ({
436
+ args: [
437
+ "mcp",
438
+ "add",
439
+ ...scope,
440
+ "whiteboard",
441
+ "--",
442
+ launch.command,
443
+ ...launch.args
444
+ ],
445
+ done: ALREADY
446
+ });
447
+ switch (target) {
448
+ case "claude": return platform === "win32" ? [windowsServer(["-s", "user"])] : [marketplace, {
449
+ args: [
450
+ "plugin",
451
+ "install",
452
+ PLUGIN,
453
+ "--scope",
454
+ "user"
455
+ ],
456
+ done: ALREADY
457
+ }];
458
+ case "codex": return [marketplace, {
459
+ args: [
460
+ "plugin",
461
+ "add",
462
+ PLUGIN
463
+ ],
464
+ done: ALREADY
465
+ }];
466
+ case "copilot": return platform === "win32" ? [windowsServer([])] : [marketplace, {
467
+ args: [
468
+ "plugin",
469
+ "install",
470
+ PLUGIN
471
+ ],
472
+ done: ALREADY
473
+ }];
474
+ case "opencode": return [{
475
+ args: [
476
+ "mcp",
477
+ "add",
478
+ "--global",
479
+ "whiteboard",
480
+ "--",
481
+ launch.command,
482
+ ...launch.args
483
+ ],
484
+ done: ALREADY
485
+ }];
486
+ case "pi": return [{
487
+ args: [
488
+ "mcp",
489
+ "add",
490
+ "whiteboard",
491
+ "--",
492
+ launch.command,
493
+ ...launch.args
494
+ ],
495
+ done: ALREADY
496
+ }];
497
+ }
498
+ }
499
+ /** oh-my-pi reads its MCP servers from one JSON file; merge ours in. */
500
+ async function connectOmp(homeDir, launch) {
501
+ const file = path.join(homeDir, ".omp", "agent", "mcp.json");
502
+ try {
503
+ const config = await exists(file) ? JSON.parse(await readFile(file, "utf8")) : {};
504
+ if (!isJsonObject(config)) throw new Error(`${file} is not a JSON object.`);
505
+ const servers = isJsonObject(config.mcpServers) ? { ...config.mcpServers } : {};
506
+ servers.whiteboard = {
507
+ command: launch.command,
508
+ args: [...launch.args]
509
+ };
510
+ const next = {
511
+ ...config,
512
+ mcpServers: servers
513
+ };
514
+ await mkdir(path.dirname(file), { recursive: true });
515
+ await writeFile(file, `${JSON.stringify(next, null, 2)}\n`);
516
+ return {
517
+ target: "omp",
518
+ state: "connected",
519
+ output: `Updated ${file}`
520
+ };
521
+ } catch (error) {
522
+ return {
523
+ target: "omp",
524
+ state: "failed",
525
+ output: error instanceof Error ? error.message : String(error)
526
+ };
527
+ }
528
+ }
529
+ /** Whether Cursor's global MCP config already names Whiteboard's server. */
530
+ async function cursorHasServer(homeDir) {
531
+ try {
532
+ const config = JSON.parse(await readFile(path.join(homeDir, ".cursor", "mcp.json"), "utf8"));
533
+ return isJsonObject(config) && isJsonObject(config.mcpServers) && config.mcpServers.whiteboard !== void 0;
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+ /** Cursor only installs an MCP server the user confirms, so open its link,
539
+ * unless it already has one: confirming again would ask for nothing new. */
540
+ async function connectCursor(homeDir, launch, platform, run) {
541
+ if (await cursorHasServer(homeDir)) return {
542
+ target: "cursor",
543
+ state: "connected",
544
+ output: "Cursor already has the whiteboard MCP server."
545
+ };
546
+ const url = cursorInstallDeeplink(launch);
547
+ const [file, args] = platform === "darwin" ? ["open", ["-g", url]] : platform === "win32" ? ["cmd", [
548
+ "/d",
549
+ "/c",
550
+ "start",
551
+ "\"\"",
552
+ url
553
+ ]] : ["xdg-open", [url]];
554
+ const result = await run(file, args);
555
+ return {
556
+ target: "cursor",
557
+ state: result.code === 0 ? "approve" : "failed",
558
+ output: result.output
559
+ };
560
+ }
561
+ /** Installs Whiteboard's plugin, or its MCP server, into one harness. */
562
+ async function connectAgent(target, input) {
563
+ const homeDir = input.homeDir ?? os.homedir();
564
+ const env = input.env ?? process.env;
565
+ const platform = input.platform ?? process.platform;
566
+ const run = input.run ?? runProcess;
567
+ const launch = reviewMcpLaunch(input.hasShim, platform);
568
+ if (target === "omp") return connectOmp(homeDir, launch);
569
+ if (target === "cursor") return connectCursor(homeDir, launch, platform, run);
570
+ const executable = await findHarness(HARNESSES[target], homeDir, env);
571
+ if (!executable) return {
572
+ target,
573
+ state: "failed",
574
+ output: `${HARNESSES[target].name} is not installed.`
575
+ };
576
+ const outputs = [];
577
+ for (const step of cliSteps(target, launch, platform)) {
578
+ const result = await run(executable, step.args);
579
+ outputs.push(result.output);
580
+ if (result.code !== 0 && !step.optional && !step.done?.test(result.output)) return {
581
+ target,
582
+ state: "failed",
583
+ output: outputs.join("\n").trim()
584
+ };
585
+ }
586
+ return {
587
+ target,
588
+ state: "connected",
589
+ output: outputs.join("\n").trim()
590
+ };
591
+ }
592
+ //#endregion
196
593
  //#region src/review-preferences.ts
197
594
  const DEFAULT_REVIEW_PREFERENCES = { scratchpadEnabled: false };
198
595
  function reviewPreferencesPath(devHome = devReviewHome()) {
@@ -726,6 +1123,1188 @@ function createJsonReviewReporting(store, telemetry, options = {}) {
726
1123
  return app;
727
1124
  }
728
1125
  //#endregion
1126
+ //#region src/server/legacy-tutorial-cleanup.ts
1127
+ /**
1128
+ * Earlier versions shipped a three-minute tutorial: a managed review over a
1129
+ * sample checkout under ~/.dev/tutorial. Whiteboard no longer has one, so
1130
+ * remove what an earlier version left behind. Best effort: a failure leaves
1131
+ * it for the next start.
1132
+ */
1133
+ async function removeLegacyTutorial(input) {
1134
+ const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
1135
+ try {
1136
+ for (const reviewId of input.store.legacyTutorialIds()) await input.store.execute({ operation: {
1137
+ type: "delete",
1138
+ reviewId
1139
+ } });
1140
+ for (const repository of input.store.repositories()) {
1141
+ const relative = path.relative(tutorialRoot, repository.path);
1142
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) await input.data.forgetRepository(repository.id);
1143
+ }
1144
+ await rm(tutorialRoot, {
1145
+ recursive: true,
1146
+ force: true,
1147
+ maxRetries: 10
1148
+ });
1149
+ } catch (error) {
1150
+ console.warn("[Whiteboard] Removing the old tutorial failed:", error);
1151
+ }
1152
+ }
1153
+ const MAX_RETRY_MS = 3e4;
1154
+ const FIRST_BYTE_TIMEOUT_MS = 1e4;
1155
+ const NO_ANSWER = `it did not answer within ${FIRST_BYTE_TIMEOUT_MS / 1e3} seconds`;
1156
+ const jitter = (ms) => ms * (.75 + Math.random() * .5);
1157
+ const STEADY_MS = 1e4;
1158
+ const MAX_LINE_CHARS = 67108864;
1159
+ const sleep = (ms, signal) => setTimeout$1(ms, void 0, { signal }).catch(() => void 0);
1160
+ async function reconnect(signal, attempt) {
1161
+ let wait = 500;
1162
+ while (!signal.aborted) {
1163
+ const started = Date.now();
1164
+ await attempt();
1165
+ if (signal.aborted) return;
1166
+ if (Date.now() - started >= STEADY_MS) wait = 500;
1167
+ await sleep(jitter(wait), signal);
1168
+ wait = Math.min(wait * 2, MAX_RETRY_MS);
1169
+ }
1170
+ }
1171
+ async function readLines(body, line) {
1172
+ const decoder = new TextDecoder();
1173
+ let pending = "";
1174
+ for await (const chunk of body) {
1175
+ pending += decoder.decode(chunk, { stream: true });
1176
+ let end;
1177
+ while ((end = pending.indexOf("\n")) !== -1) {
1178
+ line(pending.slice(0, end));
1179
+ pending = pending.slice(end + 1);
1180
+ }
1181
+ if (pending.length > MAX_LINE_CHARS) throw new Error("A line is too long.");
1182
+ }
1183
+ }
1184
+ function keepOpen(input) {
1185
+ const { remote, signal } = input;
1186
+ reconnect(signal, async () => {
1187
+ let timedOut = false;
1188
+ try {
1189
+ const response = await send(remote, {
1190
+ method: "GET",
1191
+ path: input.path,
1192
+ headers: remoteHeaders(remote),
1193
+ signal
1194
+ });
1195
+ if (response.statusCode === 200) await input.read(response.body);
1196
+ else await response.body.dump();
1197
+ } catch (error) {
1198
+ timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
1199
+ }
1200
+ if (signal.aborted) return;
1201
+ if (timedOut) input.hosts.failed(remote, NO_ANSWER);
1202
+ else input.hosts.recheck(remote);
1203
+ });
1204
+ }
1205
+ const remoteHeaders = (remote) => ({
1206
+ "x-review-token": remote.endpoint?.token ?? "",
1207
+ [REVIEW_CLIENT_HEADER]: REVIEW_CLIENT_REMOTE
1208
+ });
1209
+ async function send(remote, input, retry = true) {
1210
+ if (!remote.endpoint) throw new Error(`${remote.alias} has no endpoint.`);
1211
+ let response;
1212
+ try {
1213
+ response = await request(new URL(input.path, remote.endpoint.url), {
1214
+ dispatcher: remote.dispatcher,
1215
+ method: input.method,
1216
+ headers: input.headers,
1217
+ body: input.body,
1218
+ signal: input.signal,
1219
+ headersTimeout: input.headersTimeout ?? 1e4,
1220
+ bodyTimeout: 0
1221
+ });
1222
+ } catch (error) {
1223
+ if (retry && !input.signal?.aborted && !(input.body instanceof Readable) && errorCode(error) === "UND_ERR_SOCKET" && (input.body === void 0 || reusedSocket(error))) return send(remote, input, false);
1224
+ throw error;
1225
+ }
1226
+ if (response.statusCode === 401) remote.unauthorized?.();
1227
+ return response;
1228
+ }
1229
+ async function readBody(body, limit) {
1230
+ const parts = [];
1231
+ let size = 0;
1232
+ for await (const part of body) {
1233
+ const chunk = part;
1234
+ size += chunk.byteLength;
1235
+ if (size > limit) {
1236
+ body.destroy();
1237
+ throw new StreamLimitError();
1238
+ }
1239
+ parts.push(chunk);
1240
+ }
1241
+ return Buffer.concat(parts, size);
1242
+ }
1243
+ const codedError = z.object({ code: z.string() });
1244
+ const socketError = z.object({ socket: z.object({ bytesRead: z.number() }) });
1245
+ const reusedSocket = (cause) => (socketError.safeParse(cause).data?.socket.bytesRead ?? 0) > 0;
1246
+ const ERROR_WORDS = /* @__PURE__ */ new Map([
1247
+ ["ECONNREFUSED", "it refused the connection"],
1248
+ ["ECONNRESET", "it closed the connection"],
1249
+ ["UND_ERR_SOCKET", "it closed the connection"],
1250
+ ["EPIPE", "it closed the connection"],
1251
+ ["ETIMEDOUT", "it did not answer"],
1252
+ ["UND_ERR_HEADERS_TIMEOUT", NO_ANSWER]
1253
+ ]);
1254
+ function errorCode(cause) {
1255
+ if (!(cause instanceof Error)) return String(cause);
1256
+ return codedError.safeParse(cause).data?.code ?? codedError.safeParse(cause.cause).data?.code ?? cause.message;
1257
+ }
1258
+ function errorText(cause) {
1259
+ const code = errorCode(cause);
1260
+ return ERROR_WORDS.get(code) ?? `it failed (${code})`;
1261
+ }
1262
+ //#endregion
1263
+ //#region src/server/review-gateway-hosts.ts
1264
+ const HEALTH_TIMEOUT_MS = 3e3;
1265
+ const HEARTBEAT_MS = 1e4;
1266
+ const INSTALLS = /* @__PURE__ */ new Set(["incompatible", "not-installed"]);
1267
+ const INSTALL_STEPS = {
1268
+ preparing: "Preparing to install Whiteboard",
1269
+ "waiting-for-lock": "Waiting for another install to finish",
1270
+ package: "Installing the Whiteboard package",
1271
+ done: "Installed; starting the server"
1272
+ };
1273
+ const healthSchema = z.object({
1274
+ ok: z.literal(true),
1275
+ serverId: z.string().optional(),
1276
+ version: z.string()
1277
+ });
1278
+ function createGatewayHosts(input) {
1279
+ const log = input.log ?? (() => {});
1280
+ let hosts = [];
1281
+ let closed = false;
1282
+ const reported = /* @__PURE__ */ new Map();
1283
+ const sharing = (host) => host.serverId === void 0 ? [host] : hosts.filter((other) => other.serverId === host.serverId);
1284
+ const serving = (serverId) => serverId === void 0 ? void 0 : hosts.find((host) => host.status === "online" && host.serverId === serverId && sharing(host).length === 1);
1285
+ function stateOf(host) {
1286
+ const known = {
1287
+ alias: host.alias,
1288
+ ...host.serverId !== void 0 && { serverId: host.serverId }
1289
+ };
1290
+ const aliases = sharing(host).map((other) => other.alias);
1291
+ if (aliases.length > 1) return {
1292
+ ...known,
1293
+ state: "duplicate",
1294
+ detail: `${aliases.join(", ")} report the same server id, so Whiteboard uses none of them. If they are one machine, keep one alias. If they are separate machines, run \`whiteboard server stop\` and then \`whiteboard server reset-id\` on all but one.`
1295
+ };
1296
+ const detail = host.status === "incompatible" && host.installFailure ? `${host.detail ? `${host.detail} ` : ""}${host.installFailure}` : host.detail;
1297
+ return {
1298
+ ...known,
1299
+ state: host.status,
1300
+ ...detail !== void 0 && { detail },
1301
+ ...INSTALLS.has(host.status) && host.problem?.state !== "incompatible" && { installCommand: [`mkdir -p ~/.dev/whiteboard-remote && rm -rf ~/.dev/whiteboard-remote/whiteboard-remote && curl -fsSL https://install.dev.fast/releases/${input.version}/remote/whiteboard-remote-${input.version}-${host.target ?? "linux-x64"}.tar.xz | tar -xJ -C ~/.dev/whiteboard-remote`, "mkdir -p ~/.local/bin && ln -sf ~/.dev/whiteboard-remote/whiteboard-remote/whiteboard ~/.local/bin/whiteboard"].join("\n") },
1302
+ ...host.declined && host.status !== "online" && host.status !== "connecting" && { declined: true },
1303
+ ...host.status === "online" && languageOf(host)
1304
+ };
1305
+ }
1306
+ function report() {
1307
+ const states = hosts.map(stateOf);
1308
+ for (const host of hosts) if (host.serverId !== void 0 && serving(host.serverId) === host) input.machine?.(host.serverId, host.alias);
1309
+ for (const state of states) {
1310
+ const line = `${state.state}${state.detail ? ` (${state.detail})` : ""}`;
1311
+ if (reported.get(state.alias) === line) continue;
1312
+ reported.set(state.alias, line);
1313
+ log(`Host ${state.alias}: ${line}`);
1314
+ }
1315
+ for (const alias of reported.keys()) if (!states.some((state) => state.alias === alias)) reported.delete(alias);
1316
+ input.changed?.();
1317
+ }
1318
+ function restartedHost(host, detail = `${host.alias} restarted; attaching again.`) {
1319
+ if (closed || !host.endpoint) return;
1320
+ host.endpoint = void 0;
1321
+ clearTimeout(host.retry);
1322
+ host.checking?.abort();
1323
+ host.checking = void 0;
1324
+ host.status = "offline";
1325
+ host.detail = detail;
1326
+ report();
1327
+ input.restarted?.(host.alias);
1328
+ }
1329
+ function dispose(host) {
1330
+ clearTimeout(host.retry);
1331
+ host.checking?.abort();
1332
+ host.checking = void 0;
1333
+ host.dispatcher?.destroy();
1334
+ }
1335
+ function create(given) {
1336
+ const host = {
1337
+ alias: given.alias,
1338
+ status: "connecting",
1339
+ retryMs: 500,
1340
+ ...languageOf(given),
1341
+ ...given.target && { target: given.target }
1342
+ };
1343
+ if (given.endpoint) host.endpoint = given.endpoint;
1344
+ if (given.declined) host.declined = true;
1345
+ if (given.installFailure) host.installFailure = given.installFailure;
1346
+ if (given.problem) {
1347
+ host.problem = given.problem;
1348
+ host.status = given.problem.state;
1349
+ host.detail = given.problem.detail;
1350
+ } else if (given.installing && !given.endpoint) {
1351
+ host.installing = given.installing;
1352
+ host.status = "installing";
1353
+ host.detail = `${INSTALL_STEPS[given.installing.step]}.`;
1354
+ } else if (given.asking && !given.endpoint) {
1355
+ host.asking = given.asking;
1356
+ host.detail = `Waiting for an answer: install Whiteboard ${given.asking} on ${given.alias}?`;
1357
+ } else if (!given.endpoint) host.detail = `Waiting for a connection to ${given.alias}.`;
1358
+ else {
1359
+ host.dispatcher = new Agent();
1360
+ host.unauthorized = () => restartedHost(host);
1361
+ }
1362
+ return host;
1363
+ }
1364
+ function schedule(host, ms) {
1365
+ host.retry = setTimeout(() => void check(host), ms);
1366
+ host.retry.unref();
1367
+ }
1368
+ function retryLater(host) {
1369
+ schedule(host, jitter(host.retryMs));
1370
+ host.retryMs = Math.min(host.retryMs * 2, MAX_RETRY_MS);
1371
+ }
1372
+ async function check(host) {
1373
+ if (closed || !host.endpoint || host.problem) return;
1374
+ clearTimeout(host.retry);
1375
+ host.checking?.abort();
1376
+ const abort = new AbortController();
1377
+ host.checking = abort;
1378
+ const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
1379
+ let health;
1380
+ let reason = "it did not answer";
1381
+ try {
1382
+ const response = await send(host, {
1383
+ method: "GET",
1384
+ path: "/health",
1385
+ headers: { "x-review-token": host.endpoint.token },
1386
+ signal: AbortSignal.any([abort.signal, timeout])
1387
+ });
1388
+ const parsed = healthSchema.safeParse(JSON.parse((await readBody(response.body, 65536)).toString()));
1389
+ if (parsed.success) health = parsed.data;
1390
+ else reason = "it did not answer as a Whiteboard server";
1391
+ } catch (error) {
1392
+ if (timeout.aborted) reason = `it did not answer within ${HEALTH_TIMEOUT_MS / 1e3} seconds`;
1393
+ else if (!abort.signal.aborted) reason = errorText(error);
1394
+ }
1395
+ if (host.checking !== abort) return;
1396
+ host.checking = void 0;
1397
+ if (health && health.serverId === void 0) return restartedHost(host);
1398
+ if (!health && host.serverId !== void 0) return restartedHost(host, `${host.alias} is offline: ${reason}; attaching again.`);
1399
+ if (!health) {
1400
+ host.status = "offline";
1401
+ host.detail = `${host.alias} is offline: ${reason}.`;
1402
+ retryLater(host);
1403
+ } else {
1404
+ host.serverId = health.serverId;
1405
+ host.retryMs = 500;
1406
+ if (health.version === "unknown" || health.version !== input.version) {
1407
+ host.status = "incompatible";
1408
+ host.detail = `${host.alias} runs Whiteboard ${health.version}; this Desktop runs ${input.version}. Install Whiteboard ${input.version} on ${host.alias}.`;
1409
+ } else {
1410
+ host.status = "online";
1411
+ host.detail = void 0;
1412
+ }
1413
+ schedule(host, input.heartbeatMs ?? HEARTBEAT_MS);
1414
+ }
1415
+ report();
1416
+ }
1417
+ return {
1418
+ set(list) {
1419
+ const previous = new Map(hosts.map((host) => [host.alias, host]));
1420
+ const next = [];
1421
+ const changed = [];
1422
+ for (const given of list) {
1423
+ if (next.some((host) => host.alias === given.alias)) continue;
1424
+ const current = previous.get(given.alias);
1425
+ if (current && JSON.stringify([
1426
+ current.endpoint,
1427
+ current.problem,
1428
+ current.installing,
1429
+ current.declined,
1430
+ current.asking,
1431
+ current.installFailure
1432
+ ]) === JSON.stringify([
1433
+ given.endpoint,
1434
+ given.problem,
1435
+ given.installing,
1436
+ given.declined,
1437
+ given.asking,
1438
+ given.installFailure
1439
+ ])) {
1440
+ previous.delete(given.alias);
1441
+ current.languageFeatures = given.languageFeatures;
1442
+ current.languageFeaturesDetail = given.languageFeaturesDetail;
1443
+ current.languageGroups = given.languageGroups;
1444
+ current.target = given.target;
1445
+ next.push(current);
1446
+ } else {
1447
+ const host = create(given);
1448
+ next.push(host);
1449
+ changed.push(host);
1450
+ }
1451
+ }
1452
+ for (const gone of previous.values()) dispose(gone);
1453
+ hosts = next;
1454
+ for (const host of changed) check(host);
1455
+ report();
1456
+ },
1457
+ states: () => hosts.map(stateOf),
1458
+ serving: (serverId) => serving(serverId),
1459
+ online: () => hosts.filter((host) => serving(host.serverId) === host),
1460
+ failed(remote, reason) {
1461
+ const host = hosts.find((candidate) => candidate === remote);
1462
+ if (!host || host.status !== "online") return;
1463
+ host.status = "offline";
1464
+ host.detail = `${host.alias} is offline: ${reason}.`;
1465
+ report();
1466
+ check(host);
1467
+ },
1468
+ recheck(remote) {
1469
+ const host = hosts.find((candidate) => candidate === remote);
1470
+ if (host && !host.checking) check(host);
1471
+ },
1472
+ close() {
1473
+ closed = true;
1474
+ for (const host of hosts) dispose(host);
1475
+ }
1476
+ };
1477
+ }
1478
+ const languageOf = ({ languageFeatures, languageFeaturesDetail, languageGroups }) => ({
1479
+ ...languageFeatures !== void 0 && { languageFeatures },
1480
+ ...languageFeaturesDetail !== void 0 && { languageFeaturesDetail },
1481
+ ...languageGroups !== void 0 && { languageGroups }
1482
+ });
1483
+ //#endregion
1484
+ //#region src/server/review-gateway-memory.ts
1485
+ const LIST_MODES = ["structural", "textual"];
1486
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1487
+ const listEntrySchema = z.looseObject({
1488
+ reviewId: z.string().regex(UUID),
1489
+ version: z.number(),
1490
+ title: z.string(),
1491
+ createdAt: z.string(),
1492
+ repositoryName: z.string(),
1493
+ viewedAt: z.string().nullable(),
1494
+ trashedAt: z.string().nullable(),
1495
+ repositoryGroup: z.object({
1496
+ key: z.string(),
1497
+ label: z.string()
1498
+ }).optional()
1499
+ });
1500
+ const listEntriesSchema = z.array(z.unknown()).transform((entries) => entries.filter((entry) => listEntrySchema.safeParse(entry).success));
1501
+ const lastListSchema = z.object({
1502
+ structural: listEntriesSchema.optional(),
1503
+ textual: listEntriesSchema.optional()
1504
+ });
1505
+ const memorySchema = z.record(z.string(), z.object({
1506
+ alias: z.string(),
1507
+ lastList: lastListSchema.optional()
1508
+ }));
1509
+ function gatewayMemoryPath(home) {
1510
+ return path.join(home, "remotes.json");
1511
+ }
1512
+ function openGatewayMemory(home, log = () => {}) {
1513
+ const file = gatewayMemoryPath(home);
1514
+ const servers = /* @__PURE__ */ new Map();
1515
+ try {
1516
+ const saved = memorySchema.parse(JSON.parse(readFileSync(file, "utf8")));
1517
+ for (const [serverId, { alias, lastList }] of Object.entries(saved)) servers.set(serverId, {
1518
+ alias,
1519
+ lastList: lastList ?? {}
1520
+ });
1521
+ } catch (error) {
1522
+ if (error.code !== "ENOENT") log(`Ignoring unreadable remote review memory ${file}: ${String(error)}`);
1523
+ }
1524
+ let writing = Promise.resolve();
1525
+ let queued = false;
1526
+ const save = () => {
1527
+ if (queued) return;
1528
+ queued = true;
1529
+ writing = writing.then(() => {
1530
+ queued = false;
1531
+ return writePrivateJsonAtomic(file, Object.fromEntries(servers));
1532
+ }).catch((cause) => log(`Could not save remote review memory: ${errorMessage(cause)}`));
1533
+ };
1534
+ return {
1535
+ serverIdOf(alias) {
1536
+ for (const [serverId, server] of servers) if (server.alias === alias) return serverId;
1537
+ },
1538
+ list: (serverId, mode) => servers.get(serverId)?.lastList[mode],
1539
+ setList(serverId, alias, mode, reviews) {
1540
+ let server = servers.get(serverId);
1541
+ if (!server) {
1542
+ server = {
1543
+ alias,
1544
+ lastList: {}
1545
+ };
1546
+ servers.set(serverId, server);
1547
+ } else if (server.alias === alias && JSON.stringify(server.lastList[mode]) === JSON.stringify(reviews)) return;
1548
+ server.alias = alias;
1549
+ server.lastList[mode] = reviews;
1550
+ save();
1551
+ },
1552
+ rename(serverId, alias) {
1553
+ const server = servers.get(serverId);
1554
+ if (!server || server.alias === alias) return;
1555
+ server.alias = alias;
1556
+ save();
1557
+ },
1558
+ flush: () => writing
1559
+ };
1560
+ }
1561
+ //#endregion
1562
+ //#region src/server/review-gateway-list.ts
1563
+ function decorate(entry, state, hostState) {
1564
+ const { alias } = state;
1565
+ const online = hostState === "online";
1566
+ const features = online && state.languageFeatures === true;
1567
+ const reason = online ? features ? void 0 : state.languageFeaturesDetail : reviewHostStatus({
1568
+ alias,
1569
+ state: hostState,
1570
+ detail: hostState === state.state ? state.detail : void 0
1571
+ }).sentence;
1572
+ return {
1573
+ ...entry,
1574
+ ...entry.repositoryGroup && { repositoryGroup: {
1575
+ key: `${alias}:${entry.repositoryGroup.key}`,
1576
+ label: entry.repositoryGroup.label
1577
+ } },
1578
+ host: alias,
1579
+ hostState,
1580
+ available: {
1581
+ sourceWindows: features,
1582
+ ...reason && { reason }
1583
+ }
1584
+ };
1585
+ }
1586
+ const hostStateOf = (state, serving) => {
1587
+ if (serving) return "online";
1588
+ return state.state === "online" || state.state === "duplicate" ? "offline" : state.state;
1589
+ };
1590
+ function* servers(source) {
1591
+ const seen = /* @__PURE__ */ new Set();
1592
+ for (const state of source.states) {
1593
+ const serverId = state.serverId ?? source.serverIdOf(state.alias);
1594
+ if (serverId === void 0 || seen.has(serverId)) continue;
1595
+ seen.add(serverId);
1596
+ yield {
1597
+ state,
1598
+ serverId
1599
+ };
1600
+ }
1601
+ }
1602
+ function listedBy(reviewId, source) {
1603
+ for (const server of servers(source)) if (LIST_MODES.some((mode) => source.list(server.serverId, mode)?.some((entry) => entry.reviewId === reviewId))) return server;
1604
+ }
1605
+ function mergeLists(mode, local, source) {
1606
+ const ids = new Set(local.map((entry) => entry.reviewId));
1607
+ const merged = [...local];
1608
+ for (const { state, serverId } of servers(source)) {
1609
+ const hostState = hostStateOf(state, source.serving(serverId));
1610
+ for (const entry of source.list(serverId, mode) ?? []) if (!ids.has(entry.reviewId)) {
1611
+ ids.add(entry.reviewId);
1612
+ merged.push(decorate(entry, state, hostState));
1613
+ }
1614
+ }
1615
+ return merged;
1616
+ }
1617
+ //#endregion
1618
+ //#region src/server/review-gateway-pushes.ts
1619
+ const REMOTE_VERBS = /* @__PURE__ */ new Set([
1620
+ "authoringCapabilities",
1621
+ "openApiReview",
1622
+ "focusWindow"
1623
+ ]);
1624
+ function createGatewayPushes(input) {
1625
+ const { hosts, relay } = input;
1626
+ const links = /* @__PURE__ */ new Map();
1627
+ async function answer(remote, request) {
1628
+ if (!REMOTE_VERBS.has(request.name)) return {
1629
+ ok: false,
1630
+ error: `${request.name} is not available from another machine.`
1631
+ };
1632
+ if (request.name === "openApiReview") {
1633
+ const { reviewId } = request.args;
1634
+ if (!UUID.test(reviewId)) return {
1635
+ ok: false,
1636
+ error: `${reviewId} cannot be opened from ${remote.alias}: a review on another machine has a UUID.`
1637
+ };
1638
+ const refused = await input.claim(remote, reviewId);
1639
+ if (refused) return {
1640
+ ok: false,
1641
+ error: refused
1642
+ };
1643
+ }
1644
+ return relay.dispatch(request);
1645
+ }
1646
+ async function reply(remote, id, response) {
1647
+ try {
1648
+ await (await send(remote, {
1649
+ method: "POST",
1650
+ path: "/control/result",
1651
+ headers: {
1652
+ ...remoteHeaders(remote),
1653
+ "content-type": "application/json"
1654
+ },
1655
+ body: Buffer.from(JSON.stringify({
1656
+ id,
1657
+ response
1658
+ })),
1659
+ signal: AbortSignal.timeout(FIRST_BYTE_TIMEOUT_MS)
1660
+ })).body.dump();
1661
+ } catch (error) {
1662
+ input.log(`Could not answer ${remote.alias}'s push: ${errorText(error)}.`);
1663
+ }
1664
+ }
1665
+ async function push(remote, data) {
1666
+ let frame;
1667
+ try {
1668
+ frame = parseReviewDesktopVerbFrame(parseJsonText(data));
1669
+ } catch {
1670
+ input.log(`Ignored an unreadable push from ${remote.alias}.`);
1671
+ return;
1672
+ }
1673
+ let response;
1674
+ try {
1675
+ response = await answer(remote, frame.request);
1676
+ } catch (error) {
1677
+ response = {
1678
+ ok: false,
1679
+ error: errorText(error)
1680
+ };
1681
+ }
1682
+ await reply(remote, frame.id, response);
1683
+ }
1684
+ return {
1685
+ changed() {
1686
+ const online = new Set(relay.attached ? hosts.online() : []);
1687
+ for (const [remote, abort] of links) if (!online.has(remote)) {
1688
+ abort.abort();
1689
+ links.delete(remote);
1690
+ }
1691
+ for (const remote of online) {
1692
+ if (links.has(remote)) continue;
1693
+ const abort = new AbortController();
1694
+ links.set(remote, abort);
1695
+ keepOpen({
1696
+ hosts,
1697
+ remote,
1698
+ path: "/control",
1699
+ signal: abort.signal,
1700
+ read: (body) => readLines(body, (line) => {
1701
+ if (line.startsWith("data: ")) push(remote, line.slice(6));
1702
+ })
1703
+ });
1704
+ }
1705
+ },
1706
+ close() {
1707
+ for (const abort of links.values()) abort.abort();
1708
+ links.clear();
1709
+ }
1710
+ };
1711
+ }
1712
+ //#endregion
1713
+ //#region src/server/review-gateway-streams.ts
1714
+ const subscriptionsSchema = z.array(z.strictObject({
1715
+ reviewId: z.string().min(1).nullable(),
1716
+ mode: coverageModeSchema
1717
+ }));
1718
+ const lineSchema = z.discriminatedUnion("kind", [z.object({
1719
+ kind: z.literal("list"),
1720
+ mode: z.enum(LIST_MODES),
1721
+ reviews: z.array(z.unknown())
1722
+ }), z.object({
1723
+ kind: z.literal("review"),
1724
+ reviewId: z.string(),
1725
+ value: z.unknown().optional()
1726
+ })]);
1727
+ const downDetail = (down) => reviewHostStatus(down).sentence;
1728
+ const watchPath = (subscriptions) => `/reviews-api/watch?subscriptions=${encodeURIComponent(JSON.stringify(subscriptions))}`;
1729
+ function parseLine(text) {
1730
+ try {
1731
+ return lineSchema.safeParse(parseJsonText(text)).data;
1732
+ } catch {
1733
+ return;
1734
+ }
1735
+ }
1736
+ function createGatewayStreams(input) {
1737
+ const { hosts, memory } = input;
1738
+ const feeds = /* @__PURE__ */ new Map();
1739
+ const clients = /* @__PURE__ */ new Set();
1740
+ const passThrough = /* @__PURE__ */ new Set();
1741
+ const merge = (mode, local) => mergeLists(mode, local, input.lists());
1742
+ function feedLine(remote, text) {
1743
+ const line = parseLine(text);
1744
+ if (line?.kind !== "list" || remote.serverId === void 0) return;
1745
+ memory.setList(remote.serverId, remote.alias, line.mode, listEntriesSchema.parse(line.reviews));
1746
+ input.listed();
1747
+ for (const client of clients) client.refresh();
1748
+ }
1749
+ function syncFeeds() {
1750
+ const online = new Set(hosts.online());
1751
+ for (const [remote, abort] of feeds) if (!online.has(remote)) {
1752
+ abort.abort();
1753
+ feeds.delete(remote);
1754
+ }
1755
+ for (const remote of online) {
1756
+ if (feeds.has(remote)) continue;
1757
+ const abort = new AbortController();
1758
+ feeds.set(remote, abort);
1759
+ keepOpen({
1760
+ hosts,
1761
+ remote,
1762
+ path: watchPath(LIST_MODES.map((mode) => ({
1763
+ reviewId: null,
1764
+ mode
1765
+ }))),
1766
+ signal: abort.signal,
1767
+ read: (body) => readLines(body, (text) => feedLine(remote, text))
1768
+ });
1769
+ }
1770
+ }
1771
+ function openClient(subscriptions, signal) {
1772
+ const modes = /* @__PURE__ */ new Set();
1773
+ const reviews = /* @__PURE__ */ new Map();
1774
+ for (const { reviewId, mode } of subscriptions) if (reviewId === null) modes.add(mode);
1775
+ else if (!reviews.has(reviewId)) reviews.set(reviewId, mode);
1776
+ const encoder = new TextEncoder();
1777
+ const pending = /* @__PURE__ */ new Map();
1778
+ const sent = /* @__PURE__ */ new Map();
1779
+ const localLists = /* @__PURE__ */ new Map();
1780
+ const reportedDown = /* @__PURE__ */ new Set();
1781
+ const upstreams = /* @__PURE__ */ new Map();
1782
+ let controller;
1783
+ let flushing = false;
1784
+ let stopped = false;
1785
+ const flush = () => {
1786
+ if (!controller || !pending.size) return;
1787
+ if (controller.desiredSize === null || controller.desiredSize <= 0) return;
1788
+ const lines = [];
1789
+ for (const [item, line] of pending) if (sent.get(item) !== line) {
1790
+ lines.push(line);
1791
+ sent.set(item, line);
1792
+ }
1793
+ pending.clear();
1794
+ if (lines.length) controller.enqueue(encoder.encode(`${lines.join("\n")}\n`));
1795
+ };
1796
+ const emit = (item, line) => {
1797
+ if (stopped) return;
1798
+ pending.set(item, line);
1799
+ if (flushing) return;
1800
+ flushing = true;
1801
+ queueMicrotask(() => {
1802
+ flushing = false;
1803
+ flush();
1804
+ });
1805
+ };
1806
+ const emitList = (mode) => {
1807
+ const local = localLists.get(mode);
1808
+ if (local) emit(`list:${mode}`, JSON.stringify({
1809
+ kind: "list",
1810
+ mode,
1811
+ reviews: merge(mode, local)
1812
+ }));
1813
+ };
1814
+ const emitReview = (reviewId, text) => {
1815
+ reportedDown.delete(reviewId);
1816
+ emit(`review:${reviewId}`, text);
1817
+ };
1818
+ const down = (reviewId, state) => {
1819
+ if (reportedDown.has(reviewId)) return;
1820
+ reportedDown.add(reviewId);
1821
+ emit(`review:${reviewId}`, JSON.stringify({
1822
+ kind: "review",
1823
+ reviewId,
1824
+ error: downDetail(state)
1825
+ }));
1826
+ };
1827
+ const openLocal = (ids, abort) => {
1828
+ const path = watchPath([...[...modes].map((mode) => ({
1829
+ reviewId: null,
1830
+ mode
1831
+ })), ...[...ids].map(([reviewId, mode]) => ({
1832
+ reviewId,
1833
+ mode
1834
+ }))]);
1835
+ reconnect(abort, async () => {
1836
+ try {
1837
+ const response = await input.local(new Request(`http://gateway${path}`));
1838
+ if (response.body) await readLines(Readable.fromWeb(response.body, { signal: abort }), (text) => {
1839
+ const line = parseLine(text);
1840
+ if (line?.kind === "list" && modes.has(line.mode)) {
1841
+ localLists.set(line.mode, line.reviews);
1842
+ emitList(line.mode);
1843
+ } else if (line?.kind === "review" && ids.has(line.reviewId)) emitReview(line.reviewId, text);
1844
+ });
1845
+ } catch {}
1846
+ });
1847
+ };
1848
+ const openRemote = (remote, ids, abort) => keepOpen({
1849
+ hosts,
1850
+ remote,
1851
+ path: watchPath([...ids].map(([reviewId, mode]) => ({
1852
+ reviewId,
1853
+ mode
1854
+ }))),
1855
+ signal: abort,
1856
+ read: (body) => readLines(body, (text) => {
1857
+ const line = parseLine(text);
1858
+ if (line?.kind === "review" && ids.has(line.reviewId)) emitReview(line.reviewId, text);
1859
+ })
1860
+ });
1861
+ const client = {
1862
+ refresh() {
1863
+ if (stopped) return;
1864
+ for (const mode of modes) emitList(mode);
1865
+ const wanted = /* @__PURE__ */ new Map();
1866
+ const want = (key, reviewId) => {
1867
+ let ids = wanted.get(key);
1868
+ if (!ids) wanted.set(key, ids = /* @__PURE__ */ new Map());
1869
+ ids.set(reviewId, reviews.get(reviewId));
1870
+ };
1871
+ if (modes.size) wanted.set("local", /* @__PURE__ */ new Map());
1872
+ for (const reviewId of reviews.keys()) {
1873
+ const located = input.locate(reviewId);
1874
+ if (located === void 0 || located === "local") want("local", reviewId);
1875
+ else if ("remote" in located) want(located.remote, reviewId);
1876
+ else if (located.down.state !== "connecting") down(reviewId, located.down);
1877
+ }
1878
+ for (const [key, upstream] of upstreams) {
1879
+ const ids = wanted.get(key);
1880
+ if (ids && JSON.stringify([...ids]) === upstream.key) continue;
1881
+ upstream.abort.abort();
1882
+ upstreams.delete(key);
1883
+ }
1884
+ for (const [key, ids] of wanted) {
1885
+ if (upstreams.has(key)) continue;
1886
+ const abort = new AbortController();
1887
+ upstreams.set(key, {
1888
+ key: JSON.stringify([...ids]),
1889
+ abort
1890
+ });
1891
+ if (key === "local") openLocal(ids, abort.signal);
1892
+ else openRemote(key, ids, abort.signal);
1893
+ }
1894
+ },
1895
+ stop() {
1896
+ if (stopped) return;
1897
+ stopped = true;
1898
+ clients.delete(client);
1899
+ for (const upstream of upstreams.values()) upstream.abort.abort();
1900
+ upstreams.clear();
1901
+ try {
1902
+ controller?.close();
1903
+ } catch {}
1904
+ }
1905
+ };
1906
+ signal.addEventListener("abort", () => client.stop(), { once: true });
1907
+ const body = new ReadableStream({
1908
+ start(started) {
1909
+ controller = started;
1910
+ clients.add(client);
1911
+ client.refresh();
1912
+ },
1913
+ pull: flush,
1914
+ cancel: () => client.stop()
1915
+ });
1916
+ return new Response(body, { headers: {
1917
+ "content-type": "application/x-ndjson",
1918
+ "cache-control": "no-store"
1919
+ } });
1920
+ }
1921
+ async function passLocal(request) {
1922
+ const response = await input.local(request);
1923
+ if (!response.body) return response;
1924
+ let stop = () => {};
1925
+ const { readable, writable } = new TransformStream({ start(controller) {
1926
+ stop = () => controller.terminate();
1927
+ } });
1928
+ passThrough.add(stop);
1929
+ response.body.pipeTo(writable).catch(() => void 0).finally(() => passThrough.delete(stop));
1930
+ return new Response(readable, response);
1931
+ }
1932
+ return {
1933
+ watch(request) {
1934
+ if (!hosts.states().length) return passLocal(request);
1935
+ let subscriptions;
1936
+ try {
1937
+ subscriptions = subscriptionsSchema.parse(JSON.parse(new URL(request.url).searchParams.get("subscriptions") ?? ""));
1938
+ } catch {
1939
+ return input.local(request);
1940
+ }
1941
+ return openClient(subscriptions, request.signal);
1942
+ },
1943
+ async list(request) {
1944
+ const response = await input.local(request);
1945
+ if (!response.ok || !hosts.states().length) return response;
1946
+ const mode = coverageModeSchema.safeParse(new URL(request.url).searchParams.get("mode") ?? void 0).data ?? "structural";
1947
+ const local = await response.json();
1948
+ return serverJson(200, merge(mode, local));
1949
+ },
1950
+ changed() {
1951
+ syncFeeds();
1952
+ if (hosts.states().length) {
1953
+ for (const stop of passThrough) stop();
1954
+ passThrough.clear();
1955
+ }
1956
+ for (const client of clients) client.refresh();
1957
+ },
1958
+ close() {
1959
+ for (const abort of feeds.values()) abort.abort();
1960
+ feeds.clear();
1961
+ for (const stop of passThrough) stop();
1962
+ for (const client of clients) client.stop();
1963
+ }
1964
+ };
1965
+ }
1966
+ //#endregion
1967
+ //#region src/server/review-gateway.ts
1968
+ const LOCAL_ROUTES = /* @__PURE__ */ new Set([
1969
+ "repositories",
1970
+ "resources",
1971
+ "workspace-cleanup",
1972
+ "authoring",
1973
+ "capabilities",
1974
+ "status",
1975
+ "instructions",
1976
+ "sharing",
1977
+ "commands"
1978
+ ]);
1979
+ const REMOTE_ROUTES = [
1980
+ {
1981
+ method: "GET",
1982
+ path: ""
1983
+ },
1984
+ {
1985
+ method: "GET",
1986
+ path: "progress"
1987
+ },
1988
+ {
1989
+ method: "POST",
1990
+ path: "progress"
1991
+ },
1992
+ {
1993
+ method: "GET",
1994
+ path: "commits"
1995
+ },
1996
+ {
1997
+ method: "GET",
1998
+ path: "diff"
1999
+ },
2000
+ {
2001
+ method: "GET",
2002
+ path: "structural-diff"
2003
+ },
2004
+ {
2005
+ method: "GET",
2006
+ path: "stack"
2007
+ },
2008
+ {
2009
+ method: "GET",
2010
+ path: "tree"
2011
+ },
2012
+ {
2013
+ method: "GET",
2014
+ path: "file"
2015
+ },
2016
+ {
2017
+ method: "GET",
2018
+ path: "resources/:name"
2019
+ },
2020
+ {
2021
+ method: "GET",
2022
+ path: "maps/:name"
2023
+ },
2024
+ {
2025
+ method: "POST",
2026
+ path: "navigator"
2027
+ },
2028
+ {
2029
+ method: "POST",
2030
+ path: "copy-context"
2031
+ },
2032
+ {
2033
+ method: "GET",
2034
+ path: "language-context",
2035
+ slow: true
2036
+ },
2037
+ {
2038
+ method: "GET",
2039
+ path: "ask/agents"
2040
+ },
2041
+ {
2042
+ method: "GET",
2043
+ path: "ask/agents/:agent/offer",
2044
+ slow: true
2045
+ },
2046
+ {
2047
+ method: "GET",
2048
+ path: "ask/mentions",
2049
+ slow: true
2050
+ },
2051
+ {
2052
+ method: "GET",
2053
+ path: "ask/threads"
2054
+ },
2055
+ {
2056
+ method: "POST",
2057
+ path: "ask/watch"
2058
+ },
2059
+ {
2060
+ method: "GET",
2061
+ path: "ask/:thread"
2062
+ },
2063
+ {
2064
+ method: "POST",
2065
+ path: "ask",
2066
+ slow: true
2067
+ },
2068
+ {
2069
+ method: "POST",
2070
+ path: "ask/:thread/:action{open|choice|permissions}",
2071
+ slow: true
2072
+ },
2073
+ {
2074
+ method: "POST",
2075
+ path: "ask/:thread/:action{prompt|permission|files|retry|cancel|close}"
2076
+ },
2077
+ {
2078
+ method: "DELETE",
2079
+ path: "ask/:thread"
2080
+ }
2081
+ ];
2082
+ const SLOW_ROUTE_TIMEOUT_MS = 12e4;
2083
+ const LIST_WAIT_MS = 2e3;
2084
+ const HOP_HEADERS = /* @__PURE__ */ new Set([
2085
+ "connection",
2086
+ "keep-alive",
2087
+ "transfer-encoding",
2088
+ "content-length",
2089
+ "te",
2090
+ "trailer",
2091
+ "upgrade"
2092
+ ]);
2093
+ const DROPPED_REQUEST_HEADERS = /* @__PURE__ */ new Set([
2094
+ ...HOP_HEADERS,
2095
+ "host",
2096
+ "origin",
2097
+ "accept-encoding",
2098
+ "cookie",
2099
+ "authorization",
2100
+ "x-review-token",
2101
+ REVIEW_CLIENT_HEADER,
2102
+ REVIEW_HOST_HEADER
2103
+ ]);
2104
+ const commandTarget = z.object({ operation: z.object({ reviewId: z.string().optional() }).optional() });
2105
+ function createReviewGateway(input) {
2106
+ const log = input.log ?? (() => {});
2107
+ const memory = openGatewayMemory(input.home, log);
2108
+ let changing = false;
2109
+ let closed = false;
2110
+ const hosts = createGatewayHosts({
2111
+ version: input.version,
2112
+ log,
2113
+ machine: (serverId, alias) => memory.rename(serverId, alias),
2114
+ ...input.heartbeatMs !== void 0 && { heartbeatMs: input.heartbeatMs },
2115
+ restarted: (alias) => input.restarted?.(alias),
2116
+ changed() {
2117
+ if (changing) return;
2118
+ changing = true;
2119
+ queueMicrotask(() => {
2120
+ changing = false;
2121
+ if (closed) return;
2122
+ const online = new Set(hosts.online());
2123
+ for (const [abort, remote] of streaming) if (!online.has(remote)) abort.abort();
2124
+ streams.changed();
2125
+ pushes.changed();
2126
+ });
2127
+ }
2128
+ });
2129
+ const streaming = /* @__PURE__ */ new Map();
2130
+ const waiting = /* @__PURE__ */ new Set();
2131
+ const lists = () => ({
2132
+ states: hosts.states(),
2133
+ serving: (serverId) => hosts.serving(serverId) !== void 0,
2134
+ serverIdOf: memory.serverIdOf,
2135
+ list: memory.list
2136
+ });
2137
+ function locate(reviewId) {
2138
+ if (!UUID.test(reviewId) || input.isLocal(reviewId)) return "local";
2139
+ const listed = listedBy(reviewId, lists());
2140
+ if (!listed) return void 0;
2141
+ const remote = hosts.serving(listed.serverId);
2142
+ return remote ? { remote } : { down: listed.state };
2143
+ }
2144
+ function ownerOf(reviewId) {
2145
+ const located = locate(reviewId);
2146
+ if (located !== void 0 || !hosts.online().length) return located;
2147
+ const found = Promise.withResolvers();
2148
+ const settle = (owner) => {
2149
+ clearTimeout(timer);
2150
+ waiting.delete(check);
2151
+ found.resolve(owner);
2152
+ };
2153
+ const check = () => {
2154
+ const owner = locate(reviewId);
2155
+ if (owner !== void 0) settle(owner);
2156
+ };
2157
+ const timer = setTimeout(() => settle(void 0), LIST_WAIT_MS);
2158
+ waiting.add(check);
2159
+ return found.promise;
2160
+ }
2161
+ function answer(alias, status, body) {
2162
+ const response = serverJson(status, body);
2163
+ response.headers.set(REVIEW_HOST_HEADER, alias);
2164
+ return response;
2165
+ }
2166
+ function unavailable(down) {
2167
+ return answer(down.alias, 503, {
2168
+ ok: false,
2169
+ error: downDetail(down)
2170
+ });
2171
+ }
2172
+ async function forward(remote, request, options) {
2173
+ const url = new URL(request.url);
2174
+ const { route } = options;
2175
+ if (url.searchParams.has("token")) url.searchParams.delete("token");
2176
+ const headers = remoteHeaders(remote);
2177
+ request.headers.forEach((value, key) => {
2178
+ if (!DROPPED_REQUEST_HEADERS.has(key)) headers[key] = value;
2179
+ });
2180
+ const limit = route?.slow ? input.slowRouteMs ?? SLOW_ROUTE_TIMEOUT_MS : FIRST_BYTE_TIMEOUT_MS;
2181
+ const abort = new AbortController();
2182
+ const stream = request.body;
2183
+ let response;
2184
+ try {
2185
+ response = await send(remote, {
2186
+ method: request.method,
2187
+ path: `${url.pathname}${url.search}`,
2188
+ headers,
2189
+ body: options.body ?? (stream ? Readable.fromWeb(stream) : void 0),
2190
+ signal: AbortSignal.any([request.signal, abort.signal]),
2191
+ headersTimeout: limit
2192
+ });
2193
+ } catch (error) {
2194
+ const timedOut = errorCode(error) === "UND_ERR_HEADERS_TIMEOUT";
2195
+ const reason = timedOut ? `it did not answer within ${limit / 1e3} seconds` : errorText(error);
2196
+ if (!route?.slow && !request.signal.aborted) hosts.failed(remote, reason);
2197
+ return answer(remote.alias, timedOut ? 504 : 502, {
2198
+ ok: false,
2199
+ error: `Could not reach ${remote.alias}: ${reason}.`
2200
+ });
2201
+ }
2202
+ const { statusCode: status } = response;
2203
+ const out = new Headers();
2204
+ for (const [key, value] of Object.entries(response.headers)) if (value !== void 0 && !HOP_HEADERS.has(key) && !key.startsWith("access-control-")) out.set(key, Array.isArray(value) ? value.join(", ") : value);
2205
+ out.set(REVIEW_HOST_HEADER, remote.alias);
2206
+ if (request.method === "HEAD" || status === 204 || status === 304) {
2207
+ await response.body.dump();
2208
+ return new Response(null, {
2209
+ status,
2210
+ headers: out
2211
+ });
2212
+ }
2213
+ streaming.set(abort, remote);
2214
+ response.body.on("close", () => streaming.delete(abort));
2215
+ const piped = Readable.toWeb(response.body);
2216
+ return new Response(piped, {
2217
+ status,
2218
+ headers: out
2219
+ });
2220
+ }
2221
+ async function command(request) {
2222
+ if (!request.body) return input.local(request);
2223
+ let body;
2224
+ try {
2225
+ body = await readBoundedStream(request.body, DEFAULT_MAX_REQUEST_BYTES);
2226
+ } catch (error) {
2227
+ if (!(error instanceof StreamLimitError)) throw error;
2228
+ return serverJson(413, {
2229
+ ok: false,
2230
+ error: "Request body exceeds 1 MiB."
2231
+ });
2232
+ }
2233
+ let reviewId;
2234
+ try {
2235
+ reviewId = commandTarget.safeParse(parseJsonText(body.toString())).data?.operation?.reviewId;
2236
+ } catch {}
2237
+ const owner = reviewId === void 0 ? void 0 : await ownerOf(reviewId);
2238
+ if (owner === void 0 || owner === "local") return input.local(new Request(request.url, {
2239
+ method: request.method,
2240
+ headers: request.headers,
2241
+ body: new Uint8Array(body),
2242
+ signal: request.signal
2243
+ }));
2244
+ if ("down" in owner) return unavailable(owner.down);
2245
+ return forward(owner.remote, request, { body });
2246
+ }
2247
+ async function review(request, reviewId, route) {
2248
+ if (!reviewId || LOCAL_ROUTES.has(reviewId)) return input.local(request);
2249
+ const owner = await ownerOf(reviewId);
2250
+ if (owner === void 0 || owner === "local") return input.local(request);
2251
+ const alias = "remote" in owner ? owner.remote.alias : owner.down.alias;
2252
+ if (route === "telemetry") {
2253
+ await request.body?.cancel();
2254
+ return answer(alias, 200, { ok: true });
2255
+ }
2256
+ if (!route) return answer(alias, 404, {
2257
+ ok: false,
2258
+ error: "This route is not available for a review on another machine."
2259
+ });
2260
+ if ("down" in owner) return unavailable(owner.down);
2261
+ return forward(owner.remote, request, { route });
2262
+ }
2263
+ const app = new Hono({ strict: false }).basePath("/reviews-api");
2264
+ app.post("/commands", (context) => command(context.req.raw));
2265
+ app.get("/", (context) => streams.list(context.req.raw));
2266
+ app.get("/watch", (context) => streams.watch(context.req.raw));
2267
+ app.all("/:id/telemetry/*", (context) => review(context.req.raw, context.req.param("id"), "telemetry"));
2268
+ for (const route of REMOTE_ROUTES) app.on(route.method, `/:id${route.path && `/${route.path}`}`, (context) => review(context.req.raw, context.req.param("id") ?? "", route));
2269
+ for (const path of ["/:id", "/:id/*"]) app.all(path, (context) => review(context.req.raw, context.req.param("id") ?? ""));
2270
+ app.all("*", (context) => input.local(context.req.raw));
2271
+ const streams = createGatewayStreams({
2272
+ hosts,
2273
+ memory,
2274
+ local: input.local,
2275
+ lists,
2276
+ locate,
2277
+ listed() {
2278
+ for (const check of waiting) check();
2279
+ }
2280
+ });
2281
+ async function claim(remote, reviewId) {
2282
+ if (await ownerOf(reviewId) === void 0) return `${reviewId} is not on ${remote.alias}'s list.`;
2283
+ }
2284
+ const pushes = createGatewayPushes({
2285
+ hosts,
2286
+ relay: input.relay,
2287
+ claim,
2288
+ log
2289
+ });
2290
+ const stopWatchingWindows = input.relay.onAttachedChange(() => {
2291
+ if (!closed) pushes.changed();
2292
+ });
2293
+ return {
2294
+ fetch: (request) => app.fetch(request),
2295
+ setHosts: (list) => hosts.set(list),
2296
+ hosts: () => hosts.states(),
2297
+ async close() {
2298
+ closed = true;
2299
+ stopWatchingWindows();
2300
+ streams.close();
2301
+ pushes.close();
2302
+ hosts.close();
2303
+ await memory.flush();
2304
+ }
2305
+ };
2306
+ }
2307
+ //#endregion
729
2308
  //#region src/server/review-lifecycle-telemetry.ts
730
2309
  /**
731
2310
  * Review created, published and revoked, and agent authoring completed, as
@@ -808,154 +2387,7 @@ var ReviewOpenWatchdog = class {
808
2387
  }
809
2388
  };
810
2389
  //#endregion
811
- //#region src/server/tutorial-service.ts
812
- const authoredSchema = z.strictObject({
813
- title: z.string().min(1),
814
- document: documentSchema
815
- });
816
- const lensesSchema = z.array(lensSchema.omit({ id: true }));
817
- const pinsSchema = z.strictObject({
818
- base: z.string().regex(/^[a-f0-9]{40}$/),
819
- head: z.string().regex(/^[a-f0-9]{40}$/)
820
- });
821
- const stampSchema = z.object({
822
- version: z.literal(10),
823
- reviewUuid: z.string(),
824
- contentHash: z.string()
825
- });
826
- async function readTutorialAssets(assetsRoot) {
827
- const bytes = await Promise.all([
828
- "document.json",
829
- "lenses.json",
830
- "pins.json"
831
- ].map((name) => readFile(path.join(assetsRoot, name), "utf8")));
832
- return {
833
- authored: authoredSchema.parse(JSON.parse(bytes[0])),
834
- lenses: lensesSchema.parse(JSON.parse(bytes[1])),
835
- pins: pinsSchema.parse(JSON.parse(bytes[2])),
836
- contentHash: createHash("sha256").update(JSON.stringify(bytes)).digest("hex")
837
- };
838
- }
839
- /** Create the shipped document through the same strict validation as authored reviews. */
840
- async function createNativeTutorial(input) {
841
- const assets = await readTutorialAssets(input.assetsRoot);
842
- const repository = await input.data.register(input.sampleRoot);
843
- const pins = await input.data.resolvePins(repository.id, assets.pins.base, assets.pins.head);
844
- const result = await input.store.execute({ operation: {
845
- type: "create",
846
- title: assets.authored.title,
847
- target: {
848
- kind: "commits",
849
- ...pins
850
- }
851
- } }, {
852
- document: assets.authored.document,
853
- origin: { tutorial: true }
854
- });
855
- for (const { title, targets } of assets.lenses) await input.store.execute({ operation: {
856
- type: "lens_edit",
857
- reviewId: result.reviewId,
858
- edit: {
859
- type: "insert",
860
- title,
861
- targets
862
- }
863
- } });
864
- return {
865
- snapshot: input.store.read(result.reviewId),
866
- contentHash: assets.contentHash
867
- };
868
- }
869
- function createTutorialService(input) {
870
- const tutorialRoot = path.join(devReviewHome(), "tutorial");
871
- const sampleRoot = path.join(tutorialRoot, "sample-service");
872
- const stampPath = path.join(tutorialRoot, "stamp.json");
873
- const assetsRoot = path.join(input.packageRoot, "tutorial");
874
- const readStamp = async () => stampSchema.safeParse(await readFile(stampPath, "utf8").then((value) => JSON.parse(value)).catch(() => null)).data;
875
- async function find() {
876
- try {
877
- const stamp = await readStamp();
878
- if (!stamp || !input.store.has(stamp.reviewUuid)) return null;
879
- const snapshot = input.store.read(stamp.reviewUuid);
880
- const assets = await readTutorialAssets(assetsRoot);
881
- const [head, base] = await Promise.all([resolveRevision(sampleRoot, "HEAD"), resolveRevision(sampleRoot, "HEAD^")]);
882
- return snapshot.origin?.tutorial && stamp.contentHash === assets.contentHash && head && base && snapshot.pins?.head === head.commit && snapshot.pins?.base === base.commit && head.commit === assets.pins.head && base.commit === assets.pins.base ? snapshot : null;
883
- } catch {
884
- return null;
885
- }
886
- }
887
- async function cleanup() {
888
- for (const reviewId of input.store.tutorialIds()) {
889
- if (!input.store.read(reviewId).trashedAt) await input.store.execute({ operation: {
890
- type: "lifecycle",
891
- state: "trash",
892
- reviewId
893
- } });
894
- await input.store.execute({ operation: {
895
- type: "delete",
896
- reviewId
897
- } });
898
- }
899
- for (const repository of input.store.repositories()) {
900
- const relative = path.relative(tutorialRoot, repository.path);
901
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) await input.data.forgetRepository(repository.id);
902
- }
903
- await rm(tutorialRoot, {
904
- recursive: true,
905
- force: true,
906
- maxRetries: 10
907
- });
908
- }
909
- return {
910
- async status() {
911
- return {
912
- version: 1,
913
- reviewUuid: (await find())?.reviewId ?? null
914
- };
915
- },
916
- async referencesReview(id) {
917
- return input.store.has(id) && input.store.read(id).origin?.tutorial === true;
918
- },
919
- async prepare(options) {
920
- const current = await find();
921
- if (current) return current;
922
- await readTutorialAssets(assetsRoot);
923
- await options?.beforeReset();
924
- await cleanup();
925
- await mkdir(tutorialRoot, {
926
- recursive: true,
927
- mode: 448
928
- });
929
- const temporaryRoot = path.join(tutorialRoot, `.sample-service-${randomUUID()}`);
930
- try {
931
- await cp(path.join(assetsRoot, "sample-service"), temporaryRoot, { recursive: true });
932
- await cp(path.join(assetsRoot, "git-stub"), path.join(temporaryRoot, ".git"), { recursive: true });
933
- await rename(temporaryRoot, sampleRoot);
934
- const { snapshot, contentHash } = await createNativeTutorial({
935
- assetsRoot,
936
- sampleRoot,
937
- store: input.store,
938
- data: input.data
939
- });
940
- await writePrivateJsonAtomic(stampPath, {
941
- version: 10,
942
- reviewUuid: snapshot.reviewId,
943
- contentHash
944
- });
945
- return snapshot;
946
- } finally {
947
- await rm(temporaryRoot, {
948
- recursive: true,
949
- force: true
950
- });
951
- }
952
- },
953
- cleanup
954
- };
955
- }
956
- //#endregion
957
2390
  //#region src/server/desktop-server.ts
958
- const TUTORIAL_LIFECYCLE_LOCK_KEY = "tutorial-lifecycle";
959
2391
  function createGlobalReviewServer(input) {
960
2392
  const instanceId = input.instanceId ?? crypto.randomUUID();
961
2393
  const token = input.token ?? crypto.randomBytes(32).toString("base64url");
@@ -968,42 +2400,13 @@ function createGlobalReviewServer(input) {
968
2400
  const discoveryPaths = input.discoveryPath ? [input.discoveryPath] : identity.key === "stable" ? [reviewInstanceDiscoveryPath("stable"), reviewLegacyDiscoveryPath()] : [reviewInstanceDiscoveryPath(identity.key)];
969
2401
  const telemetry = input.telemetry ?? ReviewTelemetry.fromEnv();
970
2402
  const relay = input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
971
- const askCliEnv = () => [
972
- {
973
- name: REVIEW_INSTANCE_ENV,
974
- value: identity.key
975
- },
976
- ...process.versions.electron ? [{
977
- name: "ELECTRON_RUN_AS_NODE",
978
- value: "1"
979
- }] : [],
980
- ...process.env.DEV_REVIEW_HOME ? [{
981
- name: "DEV_REVIEW_HOME",
982
- value: process.env.DEV_REVIEW_HOME
983
- }] : []
984
- ];
985
- const askTools = {
986
- mcpServers: () => discovery.cliPath ? [{
987
- name: "whiteboard",
988
- command: process.execPath,
989
- args: [discovery.cliPath, "mcp"],
990
- env: askCliEnv()
991
- }] : [],
992
- cli: () => discovery.cliPath && [
993
- ...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
994
- shellQuote(process.execPath),
995
- shellQuote(discovery.cliPath)
996
- ].join(" ")
997
- };
2403
+ const askTools = cliAskTools(() => discovery.cliPath, {
2404
+ name: REVIEW_INSTANCE_ENV,
2405
+ value: identity.key
2406
+ });
998
2407
  const reviewStore = input.reviewStore;
999
- const reviewLocks = /* @__PURE__ */ new Map();
1000
2408
  let appReadyReported = false;
1001
2409
  const openWatchdog = new ReviewOpenWatchdog({ onTimeout: (context, elapsedMs) => void telemetry.captureEvent("review_open_timeout", { elapsed_ms: elapsedMs }, context) });
1002
- const tutorial = createTutorialService({
1003
- packageRoot: input.packageRoot,
1004
- store: reviewStore,
1005
- data: input.reviewData
1006
- });
1007
2410
  let closing = false;
1008
2411
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
1009
2412
  let scratchpadEnabled = false;
@@ -1049,8 +2452,22 @@ function createGlobalReviewServer(input) {
1049
2452
  ask: { tools: askTools }
1050
2453
  });
1051
2454
  const { app } = core;
1052
- app.route("/reviews-api", createJsonReviewReporting(input.reviewStore, telemetry, { shared: input.sharedReviews }));
1053
- app.route("/reviews-api", core.api);
2455
+ const localApi = new Hono();
2456
+ answerErrorsAsJson(localApi);
2457
+ localApi.route("/reviews-api", createJsonReviewReporting(input.reviewStore, telemetry, { shared: input.sharedReviews }));
2458
+ localApi.route("/reviews-api", core.api);
2459
+ const gateway = createReviewGateway({
2460
+ local: (request) => localApi.fetch(request),
2461
+ isLocal: (reviewId) => reviewStore.has(reviewId),
2462
+ version: readReviewPackageVersion(import.meta.url),
2463
+ home: devReviewHome(),
2464
+ relay,
2465
+ restarted: (alias) => input.onRemoteHostRestarted?.(alias),
2466
+ log: input.log
2467
+ });
2468
+ app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
2469
+ app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
2470
+ app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
1054
2471
  app.get("/preferences/scratchpad", () => serverJson(200, { enabled: scratchpadEnabled }));
1055
2472
  app.put("/preferences/scratchpad", async (context) => {
1056
2473
  const request = z.object({ enabled: z.boolean() }).safeParse(await readBoundedRequestJson(context.req.raw));
@@ -1100,21 +2517,6 @@ function createGlobalReviewServer(input) {
1100
2517
  const result = await reportCrashDump(telemetry, body.data, input.crashDumpsDir);
1101
2518
  return serverJson(result.status, result.body);
1102
2519
  });
1103
- app.get("/tutorial/status", async () => serverJson(200, await tutorial.status()));
1104
- app.post("/tutorial/prepare", async () => {
1105
- const prepared = await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, prepareTutorialLocked);
1106
- return serverJson(200, {
1107
- ok: true,
1108
- reviewUuid: prepared.reviewId
1109
- });
1110
- });
1111
- app.post("/tutorial/open", async () => {
1112
- return serverJson(200, await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, openTutorialLocked));
1113
- });
1114
- app.delete("/tutorial", async () => {
1115
- await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, deleteTutorialLocked);
1116
- return serverJson(200, { ok: true });
1117
- });
1118
2520
  app.get("/diffr-config", async () => serverJson(200, await readDiffrConfig()));
1119
2521
  app.put("/diffr-config", async (context) => {
1120
2522
  const body = await readBoundedRequestJson(context.req.raw);
@@ -1184,37 +2586,13 @@ function createGlobalReviewServer(input) {
1184
2586
  await resetCliInstall();
1185
2587
  return serverJson(200, { ok: true });
1186
2588
  });
2589
+ app.get("/install/agents", async () => serverJson(200, { agents: await detectConnectAgents() }));
2590
+ app.post("/install/connect", async (context) => {
2591
+ const request = ReviewConnectRequestSchema.safeParse(await readBoundedRequestJson(context.req.raw));
2592
+ if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
2593
+ return serverJson(200, await connectAgent(request.data.target, { hasShim: existsSync(pathShimPath()) }));
2594
+ });
1187
2595
  const httpServer = createServer(createNodeRequestListener(app));
1188
- async function prepareTutorialLocked() {
1189
- return tutorial.prepare();
1190
- }
1191
- async function openTutorialLocked() {
1192
- const snapshot = await prepareTutorialLocked();
1193
- return {
1194
- kind: "api",
1195
- reviewUuid: snapshot.reviewId,
1196
- title: snapshot.title
1197
- };
1198
- }
1199
- async function deleteTutorialLocked() {
1200
- await tutorial.cleanup();
1201
- }
1202
- async function withReviewLock(reviewUuid, operation) {
1203
- const previous = reviewLocks.get(reviewUuid) ?? Promise.resolve();
1204
- let release = () => {};
1205
- const current = new Promise((resolve) => {
1206
- release = resolve;
1207
- });
1208
- const chain = previous.then(() => current);
1209
- reviewLocks.set(reviewUuid, chain);
1210
- await previous;
1211
- try {
1212
- return await operation();
1213
- } finally {
1214
- release();
1215
- if (reviewLocks.get(reviewUuid) === chain) reviewLocks.delete(reviewUuid);
1216
- }
1217
- }
1218
2596
  return {
1219
2597
  discovery,
1220
2598
  get url() {
@@ -1222,6 +2600,10 @@ function createGlobalReviewServer(input) {
1222
2600
  },
1223
2601
  listen: async () => {
1224
2602
  await migrateDiffrConfig();
2603
+ await removeLegacyTutorial({
2604
+ store: reviewStore,
2605
+ data: input.reviewData
2606
+ });
1225
2607
  scratchpadEnabled = await readScratchpadEnabled();
1226
2608
  boundPort = await listen(httpServer, input.port);
1227
2609
  discovery.url = urlForBoundPort();
@@ -1235,8 +2617,10 @@ function createGlobalReviewServer(input) {
1235
2617
  relay.close();
1236
2618
  openWatchdog.dispose();
1237
2619
  await closeHttpServer(httpServer);
2620
+ await gateway.close();
1238
2621
  await telemetry.shutdown(1500);
1239
- }
2622
+ },
2623
+ setRemoteHosts: (hosts) => gateway.setHosts(hosts)
1240
2624
  };
1241
2625
  }
1242
2626
  function watchSessionOpen(watchdog, event, context) {
@@ -1332,25 +2716,38 @@ async function runDesktopHost(env = process.env) {
1332
2716
  };
1333
2717
  const home = devReviewHome(env);
1334
2718
  await mkdir(home, { recursive: true });
1335
- const local = await openReviewProfile(home, { manageWorkspaces: true });
1336
- const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
1337
- shared.connect(local.store, local.data);
1338
- await shared.load();
1339
- const server = createGlobalReviewServer({
1340
- ...serverInput,
1341
- sharedReviews: shared,
1342
- reviewStore: local.store,
1343
- reviewData: local.data,
1344
- cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
1345
- crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR
2719
+ const log = (message) => process.stdout.write(`[Whiteboard gateway] ${message}\n`);
2720
+ const { local, shared, server } = await takeOverServerHome(home, async () => {
2721
+ const local = await openReviewProfile(home, { manageWorkspaces: true });
2722
+ const shared = new SharedReviewStore(path.join(home, "shared-reviews"));
2723
+ shared.connect(local.store, local.data);
2724
+ await shared.load();
2725
+ const server = createGlobalReviewServer({
2726
+ ...serverInput,
2727
+ log,
2728
+ sharedReviews: shared,
2729
+ reviewStore: local.store,
2730
+ reviewData: local.data,
2731
+ cliRuntimePath: env.DEV_FAST_REVIEW_CLI_RUNTIME,
2732
+ crashDumpsDir: env.DEV_FAST_REVIEW_CRASH_DUMPS_DIR,
2733
+ onRemoteHostRestarted: (alias) => postToDesktop(process, {
2734
+ type: "remote-host-restarted",
2735
+ alias
2736
+ })
2737
+ });
2738
+ try {
2739
+ await server.listen();
2740
+ } catch (error) {
2741
+ await local?.data.close();
2742
+ await local?.store.close();
2743
+ throw error;
2744
+ }
2745
+ return {
2746
+ local,
2747
+ shared,
2748
+ server
2749
+ };
1346
2750
  });
1347
- try {
1348
- await server.listen();
1349
- } catch (error) {
1350
- await local?.data.close();
1351
- await local?.store.close();
1352
- throw error;
1353
- }
1354
2751
  process.stdout.write(`${JSON.stringify({
1355
2752
  event: "ready",
1356
2753
  ...server.discovery,
@@ -1373,7 +2770,7 @@ async function runDesktopHost(env = process.env) {
1373
2770
  }, (sourcePath) => {
1374
2771
  env.DEV_FAST_REVIEW_RUST_ANALYZER = sourcePath;
1375
2772
  stageRustAnalyzer();
1376
- });
2773
+ }, (hosts) => server.setRemoteHosts(hosts));
1377
2774
  process.once("SIGINT", () => {
1378
2775
  stop().then(() => process.exit(0));
1379
2776
  });