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

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 +9 -14
  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
@@ -12,19 +12,22 @@ import {
12
12
  type JsonValue,
13
13
  REVIEW_DESKTOP_DISCOVERY_VERSION,
14
14
  type ReviewCliInstallApplyResponse,
15
+ ReviewConnectRequestSchema,
15
16
  type ReviewDesktopDiscovery,
16
- type ReviewTutorialOpenResponse,
17
+ type ReviewGatewayHost,
17
18
  isJsonObject,
18
19
  isObjectValue,
19
20
  parseReviewCliInstallApplyRequest,
20
21
  reviewDiffrSummarizerInputSchema,
21
22
  } from "@dev.fast/review-protocol";
22
23
  import { shellQuote, writePrivateJsonAtomic } from "@dev.fast/trace-core";
23
- import type { AskTools } from "@review/ask/threads.js";
24
+ import { connectAgent, detectConnectAgents } from "@review/agent-connect";
25
+ import { type AskTools, cliAskTools } from "@review/ask/threads.js";
24
26
  import {
25
27
  applyCliInstall,
26
28
  declineCliInstall,
27
29
  finishCliInstallUpdate,
30
+ pathShimPath,
28
31
  removeCliInstall,
29
32
  removeLegacyReviewSkills,
30
33
  resetCliInstall,
@@ -52,6 +55,7 @@ import {
52
55
  type ReviewTelemetryContext,
53
56
  } from "@review/review-telemetry";
54
57
  import type { SharedReviewStore } from "@review/sharing/import.js";
58
+ import { Hono } from "hono";
55
59
  import { z } from "zod";
56
60
 
57
61
  import { aliasInstallationToAccount } from "./account-alias";
@@ -67,18 +71,25 @@ import {
67
71
  GlobalReviewDesktopVerbRelay,
68
72
  type ReviewDesktopVerbRelay,
69
73
  } from "./global-verb-relay";
70
- import { createNodeRequestListener, readBoundedRequestJson } from "./hono-http";
74
+ import {
75
+ type ReviewHonoEnv,
76
+ createNodeRequestListener,
77
+ readBoundedRequestJson,
78
+ } from "./hono-http";
71
79
  import { ReviewServerError } from "./http-json";
72
80
  import { createJsonReviewReporting } from "./json-review-reporting";
81
+ import { removeLegacyTutorial } from "./legacy-tutorial-cleanup";
82
+ import { createReviewGateway } from "./review-gateway";
73
83
  import { reviewLifecycleTelemetry } from "./review-lifecycle-telemetry";
74
84
  import { ReviewOpenWatchdog } from "./review-open-watchdog";
75
- import { createWhiteboardCore, serverJson } from "./review-server-core";
85
+ import {
86
+ answerErrorsAsJson,
87
+ createWhiteboardCore,
88
+ serverJson,
89
+ } from "./review-server-core";
76
90
  import { invalidateStructuralComparisons } from "./structural-comparisons.js";
77
- import { createTutorialService } from "./tutorial-service";
78
91
  import { captureSanitizedUiTelemetry } from "./ui-telemetry";
79
92
 
80
- const TUTORIAL_LIFECYCLE_LOCK_KEY = "tutorial-lifecycle";
81
-
82
93
  const UUID_PATTERN =
83
94
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
84
95
 
@@ -100,6 +111,8 @@ export interface GlobalReviewServerInput {
100
111
  relay?: ReviewDesktopVerbRelay;
101
112
  /** Electron's Review crash dump directory; `/crash-reports` reads only inside it. */
102
113
  crashDumpsDir?: string;
114
+ onRemoteHostRestarted?(alias: string): void;
115
+ log?(message: string): void;
103
116
  }
104
117
 
105
118
  export interface GlobalReviewServer {
@@ -107,6 +120,7 @@ export interface GlobalReviewServer {
107
120
  readonly url: string;
108
121
  listen(): Promise<void>;
109
122
  close(reason?: "app-exit"): Promise<void>;
123
+ setRemoteHosts(hosts: ReviewGatewayHost[]): void;
110
124
  }
111
125
 
112
126
  export function createGlobalReviewServer(
@@ -135,45 +149,13 @@ export function createGlobalReviewServer(
135
149
  const relay =
136
150
  input.relay ?? new GlobalReviewDesktopVerbRelay({ maxClients: 1 });
137
151
 
138
- // This Desktop's own CLI, pinned to this instance so another running
139
- // Whiteboard never answers it.
140
- const askCliEnv = () => [
141
- { name: REVIEW_INSTANCE_ENV, value: identity.key },
142
- ...(process.versions.electron
143
- ? [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }]
144
- : []),
145
- ...(process.env.DEV_REVIEW_HOME
146
- ? [{ name: "DEV_REVIEW_HOME", value: process.env.DEV_REVIEW_HOME }]
147
- : []),
148
- ];
149
-
150
- // Ask sessions get its MCP server, `whiteboard mcp`; an agent whose model
151
- // would not get it uses `whiteboard api` from its shell instead.
152
- const askTools: AskTools = {
153
- mcpServers: () =>
154
- discovery.cliPath
155
- ? [
156
- {
157
- name: "whiteboard",
158
- command: process.execPath,
159
- args: [discovery.cliPath, "mcp"],
160
- env: askCliEnv(),
161
- },
162
- ]
163
- : [],
164
- cli: () =>
165
- discovery.cliPath &&
166
- [
167
- ...askCliEnv().map(({ name, value }) => `${name}=${shellQuote(value)}`),
168
- shellQuote(process.execPath),
169
- shellQuote(discovery.cliPath),
170
- ].join(" "),
171
- };
152
+ const askTools = cliAskTools(() => discovery.cliPath, {
153
+ name: REVIEW_INSTANCE_ENV,
154
+ value: identity.key,
155
+ });
172
156
 
173
157
  const reviewStore = input.reviewStore;
174
158
 
175
- const reviewLocks = new Map<string, Promise<void>>();
176
-
177
159
  // A reload or a second window starts the workbench again, but the launch
178
160
  // this server belongs to became ready once.
179
161
  let appReadyReported = false;
@@ -187,12 +169,6 @@ export function createGlobalReviewServer(
187
169
  ),
188
170
  });
189
171
 
190
- const tutorial = createTutorialService({
191
- packageRoot: input.packageRoot,
192
- store: reviewStore,
193
- data: input.reviewData,
194
- });
195
-
196
172
  let closing = false;
197
173
  const cliPath = path.join(input.packageRoot, "dist", "cli.js");
198
174
 
@@ -258,14 +234,31 @@ export function createGlobalReviewServer(
258
234
 
259
235
  const { app } = core;
260
236
 
261
- app.route(
237
+ const localApi = new Hono<ReviewHonoEnv>();
238
+ answerErrorsAsJson(localApi);
239
+
240
+ localApi.route(
262
241
  "/reviews-api",
263
242
  createJsonReviewReporting(input.reviewStore, telemetry, {
264
243
  shared: input.sharedReviews,
265
244
  }),
266
245
  );
267
246
 
268
- app.route("/reviews-api", core.api);
247
+ localApi.route("/reviews-api", core.api);
248
+
249
+ const gateway = createReviewGateway({
250
+ local: (request) => localApi.fetch(request),
251
+ isLocal: (reviewId) => reviewStore.has(reviewId),
252
+ version: readReviewPackageVersion(import.meta.url),
253
+ home: devReviewHome(),
254
+ relay,
255
+ restarted: (alias) => input.onRemoteHostRestarted?.(alias),
256
+ log: input.log,
257
+ });
258
+
259
+ app.all("/reviews-api", (context) => gateway.fetch(context.req.raw));
260
+ app.all("/reviews-api/*", (context) => gateway.fetch(context.req.raw));
261
+ app.get("/remote-hosts", () => serverJson(200, gateway.hosts()));
269
262
  app.get("/preferences/scratchpad", () =>
270
263
  serverJson(200, { enabled: scratchpadEnabled }),
271
264
  );
@@ -372,32 +365,6 @@ export function createGlobalReviewServer(
372
365
 
373
366
  return serverJson(result.status, result.body);
374
367
  });
375
- app.get("/tutorial/status", async () =>
376
- serverJson(200, await tutorial.status()),
377
- );
378
- app.post("/tutorial/prepare", async () => {
379
- const prepared = await withReviewLock(
380
- TUTORIAL_LIFECYCLE_LOCK_KEY,
381
- prepareTutorialLocked,
382
- );
383
-
384
- return serverJson(200, {
385
- ok: true,
386
- reviewUuid: prepared.reviewId,
387
- });
388
- });
389
- app.post("/tutorial/open", async () => {
390
- return serverJson(
391
- 200,
392
- await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, openTutorialLocked),
393
- );
394
- });
395
- app.delete("/tutorial", async () => {
396
- await withReviewLock(TUTORIAL_LIFECYCLE_LOCK_KEY, deleteTutorialLocked);
397
-
398
- return serverJson(200, { ok: true });
399
- });
400
-
401
368
  app.get("/diffr-config", async () =>
402
369
  serverJson(200, await readDiffrConfig()),
403
370
  );
@@ -517,50 +484,27 @@ export function createGlobalReviewServer(
517
484
 
518
485
  return serverJson(200, { ok: true });
519
486
  });
487
+ // The harnesses first run connects for the user, instead of asking them
488
+ // to paste a prompt into each one.
489
+ app.get("/install/agents", async () =>
490
+ serverJson(200, { agents: await detectConnectAgents() }),
491
+ );
492
+ app.post("/install/connect", async (context) => {
493
+ const request = ReviewConnectRequestSchema.safeParse(
494
+ await readBoundedRequestJson(context.req.raw),
495
+ );
520
496
 
521
- const httpServer = createServer(createNodeRequestListener(app));
522
-
523
- async function prepareTutorialLocked() {
524
- return tutorial.prepare();
525
- }
526
-
527
- async function openTutorialLocked(): Promise<ReviewTutorialOpenResponse> {
528
- const snapshot = await prepareTutorialLocked();
529
-
530
- return {
531
- kind: "api",
532
- reviewUuid: snapshot.reviewId,
533
- title: snapshot.title,
534
- };
535
- }
536
-
537
- async function deleteTutorialLocked(): Promise<void> {
538
- await tutorial.cleanup();
539
- }
540
-
541
- async function withReviewLock<T>(
542
- reviewUuid: string,
543
- operation: () => Promise<T>,
544
- ): Promise<T> {
545
- const previous = reviewLocks.get(reviewUuid) ?? Promise.resolve();
546
- let release: () => void = () => {};
547
-
548
- const current = new Promise<void>((resolve) => {
549
- release = resolve;
550
- });
551
-
552
- const chain = previous.then(() => current);
553
- reviewLocks.set(reviewUuid, chain);
554
- await previous;
497
+ if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
555
498
 
556
- try {
557
- return await operation();
558
- } finally {
559
- release();
499
+ return serverJson(
500
+ 200,
501
+ await connectAgent(request.data.target, {
502
+ hasShim: existsSync(pathShimPath()),
503
+ }),
504
+ );
505
+ });
560
506
 
561
- if (reviewLocks.get(reviewUuid) === chain) reviewLocks.delete(reviewUuid);
562
- }
563
- }
507
+ const httpServer = createServer(createNodeRequestListener(app));
564
508
 
565
509
  return {
566
510
  discovery,
@@ -569,6 +513,10 @@ export function createGlobalReviewServer(
569
513
  },
570
514
  listen: async () => {
571
515
  await migrateDiffrConfig();
516
+ await removeLegacyTutorial({
517
+ store: reviewStore,
518
+ data: input.reviewData,
519
+ });
572
520
  scratchpadEnabled = await readScratchpadEnabled();
573
521
  boundPort = await listen(httpServer, input.port);
574
522
  discovery.url = urlForBoundPort();
@@ -587,8 +535,10 @@ export function createGlobalReviewServer(
587
535
  openWatchdog.dispose();
588
536
 
589
537
  await closeHttpServer(httpServer);
538
+ await gateway.close();
590
539
  await telemetry.shutdown(1_500);
591
540
  },
541
+ setRemoteHosts: (hosts) => gateway.setHosts(hosts),
592
542
  };
593
543
  }
594
544
 
@@ -35,6 +35,7 @@ export interface ReviewDesktopVerbRelay {
35
35
  dispatch(value: JsonValue): Promise<ReviewVerbResponse>;
36
36
  acceptResult(value: JsonValue): boolean;
37
37
  close(): void;
38
+ onAttachedChange(listener: () => void): () => void;
38
39
  }
39
40
 
40
41
  /**
@@ -51,6 +52,7 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
51
52
  private readonly pending = new Map<string, PendingVerb>();
52
53
  private readonly timeoutMs: number;
53
54
  private readonly maxClients: number;
55
+ private readonly attachedListeners = new Set<() => void>();
54
56
 
55
57
  constructor(options: { timeoutMs?: number; maxClients?: number } = {}) {
56
58
  this.timeoutMs = options.timeoutMs ?? DEFAULT_VERB_TIMEOUT_MS;
@@ -73,9 +75,21 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
73
75
  this.clients.set(writer, detach);
74
76
  writer.signal.addEventListener("abort", detach, { once: true });
75
77
 
78
+ if (this.clients.size === 1) this.notifyAttached();
79
+
76
80
  return true;
77
81
  }
78
82
 
83
+ onAttachedChange(listener: () => void): () => void {
84
+ this.attachedListeners.add(listener);
85
+
86
+ return () => this.attachedListeners.delete(listener);
87
+ }
88
+
89
+ private notifyAttached(): void {
90
+ for (const listener of [...this.attachedListeners]) listener();
91
+ }
92
+
79
93
  dispatch(value: JsonValue): Promise<ReviewVerbResponse> {
80
94
  const request: ReviewVerbRequest = parseReviewVerbRequest(value);
81
95
 
@@ -170,6 +184,8 @@ export class GlobalReviewDesktopVerbRelay implements ReviewDesktopVerbRelay {
170
184
  for (const [id, verb] of [...this.pending]) {
171
185
  if (verb.waiting.get(id) === writer) this.stopWaiting(verb, id);
172
186
  }
187
+
188
+ if (this.clients.size === 0) this.notifyAttached();
173
189
  }
174
190
 
175
191
  /** Drops one client's id; with nobody left to answer, the verb fails. */
@@ -1,17 +1,32 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { randomBytes, randomUUID } from "node:crypto";
2
3
  import { once } from "node:events";
4
+ import { existsSync } from "node:fs";
3
5
  import { mkdir, realpath, rm } from "node:fs/promises";
4
6
  import { createServer } from "node:http";
7
+ import path from "node:path";
8
+ import { setTimeout as delay } from "node:timers/promises";
5
9
 
6
10
  import { isObjectValue } from "@dev.fast/json";
7
11
  import { withFileLock, writePrivateJsonAtomic } from "@dev.fast/trace-core";
12
+ import type { AskAgentLauncher } from "@review/ask/agents.js";
13
+ import { cliAskTools } from "@review/ask/threads.js";
14
+ import { desktopUsesHome } from "@review/desktop-discovery.js";
15
+ import { findReviewPackageRoot } from "@review/package-paths.js";
8
16
  import { openReviewProfile } from "@review/review-api/profile.js";
9
17
  import {
18
+ DESKTOP_TAKEOVER_SIGNAL,
10
19
  type ReviewServerDiscovery,
20
+ desktopOwnsHome,
11
21
  headlessServerLockPath,
22
+ readReviewServerDiscovery,
12
23
  reviewServerDiscoveryPath,
13
24
  } from "@review/server-discovery.js";
14
25
 
26
+ import {
27
+ headlessServerOwner,
28
+ stopBackgroundServer,
29
+ } from "./background-server.js";
15
30
  import { migrateDiffrConfig } from "./diffr-config.js";
16
31
  import { GlobalReviewDesktopVerbRelay } from "./global-verb-relay.js";
17
32
  import { createNodeRequestListener } from "./hono-http.js";
@@ -26,12 +41,16 @@ interface HeadlessServerInput {
26
41
  stateDir: string;
27
42
  port?: number;
28
43
  softwareMapEnabled?: boolean;
44
+ startedBy?: ReviewServerDiscovery["startedBy"];
29
45
  signal: AbortSignal;
30
46
  /** The CLI's instance, already on the `headless` surface. */
31
47
  telemetry?: Pick<ReviewTelemetryCapture, "captureUiEvent">;
32
48
  onReady(discovery: ReviewServerDiscovery): void;
49
+ launchAskAgent?: AskAgentLauncher;
33
50
  }
34
51
 
52
+ export class HeadlessServerBusyError extends Error {}
53
+
35
54
  /** One foreground headless endpoint per profile; Desktop shares its database. */
36
55
  export async function runHeadlessServer(input: HeadlessServerInput) {
37
56
  await mkdir(input.stateDir, { recursive: true, mode: 0o700 });
@@ -45,7 +64,7 @@ export async function runHeadlessServer(input: HeadlessServerInput) {
45
64
  ).finally(() => stopErrorTelemetry?.());
46
65
 
47
66
  if (!outcome.acquired)
48
- throw new Error(
67
+ throw new HeadlessServerBusyError(
49
68
  `A Whiteboard server already owns ${stateDir}. Stop it first, or choose another --state-dir.`,
50
69
  );
51
70
  }
@@ -70,15 +89,104 @@ export function withHeadlessServerLock<T>(
70
89
  );
71
90
  }
72
91
 
92
+ /**
93
+ * Desktop owns its home: it stops any server there, then starts under that
94
+ * server's lock, so none can start until Desktop has published its discovery.
95
+ */
96
+ export async function takeOverServerHome<T>(
97
+ home: string,
98
+ start: () => Promise<T>,
99
+ ): Promise<T> {
100
+ const stateDir = await realpath(home);
101
+ const deadline = Date.now() + 30_000;
102
+
103
+ while (true) {
104
+ const outcome = await withHeadlessServerLock(stateDir, start);
105
+
106
+ if (outcome.acquired) return outcome.result;
107
+
108
+ const owner = await headlessServerOwner(stateDir);
109
+
110
+ const discovery = await readReviewServerDiscovery(stateDir).catch(
111
+ () => null,
112
+ );
113
+
114
+ if (owner !== undefined && discovery?.serverPid === owner)
115
+ await stopBackgroundServer(discovery, DESKTOP_TAKEOVER_SIGNAL);
116
+ else if (Date.now() > deadline)
117
+ throw new Error(
118
+ `Process ${owner} holds the Whiteboard server's lock in ${stateDir} without serving it.`,
119
+ );
120
+ else await delay(100);
121
+ }
122
+ }
123
+
124
+ const LOGIN_PATH_TIMEOUT_MS = 4_000;
125
+
126
+ /** Started over ssh, this server has sshd's PATH; agents are on the login
127
+ * shell's. */
128
+ async function agentEnv(): Promise<NodeJS.ProcessEnv> {
129
+ const login = process.env.SHELL && (await loginPath(process.env.SHELL));
130
+
131
+ return {
132
+ ...process.env,
133
+ PATH: [login, process.env.PATH, path.dirname(process.execPath)]
134
+ .filter(Boolean)
135
+ .join(path.delimiter),
136
+ };
137
+ }
138
+
139
+ function loginPath(shell: string) {
140
+ return new Promise<string | undefined>((resolve) => {
141
+ const child = spawn(
142
+ shell,
143
+ ["-lic", 'printf "\\nWHITEBOARD-PATH=%s\\n" "$PATH"'],
144
+ { detached: true, stdio: ["ignore", "pipe", "ignore"] },
145
+ );
146
+
147
+ let output = "";
148
+
149
+ child.stdout.setEncoding("utf8");
150
+ child.stdout.on("data", (chunk: string) => (output += chunk));
151
+
152
+ const timer = setTimeout(() => {
153
+ try {
154
+ process.kill(-child.pid!, "SIGKILL");
155
+ } catch {}
156
+
157
+ resolve(undefined);
158
+ }, LOGIN_PATH_TIMEOUT_MS);
159
+
160
+ child.once("error", () => {
161
+ clearTimeout(timer);
162
+ resolve(undefined);
163
+ });
164
+ child.once("close", () => {
165
+ clearTimeout(timer);
166
+
167
+ const found = output
168
+ .replaceAll("\r", "")
169
+ .split("\n")
170
+ .findLast((line) => line.startsWith("WHITEBOARD-PATH="))
171
+ ?.slice("WHITEBOARD-PATH=".length);
172
+
173
+ resolve(found?.startsWith("/") ? found : undefined);
174
+ });
175
+ });
176
+ }
177
+
73
178
  async function serve(input: HeadlessServerInput) {
74
179
  if (input.signal.aborted) return;
75
180
 
181
+ if (await desktopUsesHome(input.stateDir))
182
+ throw new HeadlessServerBusyError(desktopOwnsHome(input.stateDir));
183
+
76
184
  await migrateDiffrConfig(input.signal);
77
185
 
78
186
  if (input.telemetry) await drainServerCrashReport(input.telemetry);
79
187
 
80
188
  const local = await openReviewProfile(input.stateDir, {
81
- manageWorkspaces: false,
189
+ manageWorkspaces: true,
82
190
  });
83
191
 
84
192
  const discovery: ReviewServerDiscovery = {
@@ -87,11 +195,18 @@ async function serve(input: HeadlessServerInput) {
87
195
  url: "http://127.0.0.1:0",
88
196
  serverPid: process.pid,
89
197
  token: randomBytes(32).toString("base64url"),
198
+ startedBy: input.startedBy ?? "user",
90
199
  };
91
200
 
92
201
  const relay = new GlobalReviewDesktopVerbRelay();
93
202
 
94
- const { app, api } = createWhiteboardCore({
203
+ const cliPath = path.join(
204
+ findReviewPackageRoot(import.meta.url),
205
+ "dist",
206
+ "cli.js",
207
+ );
208
+
209
+ const { app, api, close } = createWhiteboardCore({
95
210
  profile: local,
96
211
  relay,
97
212
  token: discovery.token,
@@ -100,6 +215,14 @@ async function serve(input: HeadlessServerInput) {
100
215
  // The scratchpad is the laptop's alone, even with a Desktop attached.
101
216
  scratchpad: () => false,
102
217
  status: () => ({ key: "headless", home: input.stateDir }),
218
+ ask: {
219
+ tools: cliAskTools(() => (existsSync(cliPath) ? cliPath : undefined), {
220
+ name: "DEV_REVIEW_SERVER_DIR",
221
+ value: input.stateDir,
222
+ }),
223
+ launch: input.launchAskAgent,
224
+ env: agentEnv(),
225
+ },
103
226
  });
104
227
 
105
228
  app.route("/reviews-api", api);
@@ -129,6 +252,8 @@ async function serve(input: HeadlessServerInput) {
129
252
  input.signal.addEventListener("abort", () => resolve(), { once: true });
130
253
  });
131
254
  } finally {
255
+ close();
256
+
132
257
  // Watch streams may live forever. Drain ordinary requests, then bound shutdown.
133
258
  const forceClose = setTimeout(() => server.closeAllConnections(), 5_000);
134
259
  forceClose.unref();
@@ -1,7 +1,11 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
3
 
4
- import { type JsonValue, parseJsonText } from "@dev.fast/review-protocol";
4
+ import {
5
+ type JsonValue,
6
+ REVIEW_HOST_HEADER,
7
+ parseJsonText,
8
+ } from "@dev.fast/review-protocol";
5
9
  import { type HttpBindings, getRequestListener } from "@hono/node-server";
6
10
  import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
7
11
  import type { Hono } from "hono";
@@ -76,6 +80,7 @@ export function applyCorsHeaders(
76
80
  "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
77
81
  );
78
82
  response.headers.set("access-control-allow-private-network", "true");
83
+ response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
79
84
 
80
85
  return response;
81
86
  }
@@ -0,0 +1,38 @@
1
+ import { rm } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import type { LocalReviewData } from "@review/review-api/local-data";
5
+ import type { ReviewStore } from "@review/review-api/store";
6
+ import { devReviewHome } from "@review/review-home-paths";
7
+
8
+ /**
9
+ * Earlier versions shipped a three-minute tutorial: a managed review over a
10
+ * sample checkout under ~/.dev/tutorial. Whiteboard no longer has one, so
11
+ * remove what an earlier version left behind. Best effort: a failure leaves
12
+ * it for the next start.
13
+ */
14
+ export async function removeLegacyTutorial(input: {
15
+ store: ReviewStore;
16
+ data: LocalReviewData;
17
+ root?: string;
18
+ }): Promise<void> {
19
+ const tutorialRoot = input.root ?? path.join(devReviewHome(), "tutorial");
20
+
21
+ try {
22
+ for (const reviewId of input.store.legacyTutorialIds())
23
+ await input.store.execute({ operation: { type: "delete", reviewId } });
24
+
25
+ for (const repository of input.store.repositories()) {
26
+ const relative = path.relative(tutorialRoot, repository.path);
27
+
28
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative))
29
+ await input.data.forgetRepository(repository.id);
30
+ }
31
+
32
+ // Windows cannot delete a checkout while its Git reader or watchers
33
+ // hold it open, and handles can outlive their close briefly.
34
+ await rm(tutorialRoot, { recursive: true, force: true, maxRetries: 10 });
35
+ } catch (error) {
36
+ console.warn("[Whiteboard] Removing the old tutorial failed:", error);
37
+ }
38
+ }