@dev.fast/whiteboard 0.3.5-preview.20261021.131 → 0.3.5-preview.20261021.133

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.
@@ -4,7 +4,7 @@ import { C as disableTraceMachine, E as traceMachineStatus, G as listTraceReposi
4
4
  import { I as writeFileAtomicAsync, L as writePrivateJsonAtomic, N as devReviewHome, j as withFileLock } from "./s3-config-CjcLyFV5.js";
5
5
  import { i as readReviewPackageVersion } from "./package-paths-BkeQsdaw.js";
6
6
  import { n as isFile, t as isDirectory } from "./fs-utils-BMPLt0cr.js";
7
- import { c as findExecutable, s as executableCommand } from "./fuzzy-match-D_x3wumC.js";
7
+ import { c as findExecutable, s as executableCommand } from "./fuzzy-match-SsirV9Ap.js";
8
8
  import { createHash } from "node:crypto";
9
9
  import { constants } from "node:fs";
10
10
  import { access, chmod, lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
@@ -4,8 +4,8 @@ import { a as toolResultText, i as connectReviewInstance, s as ReviewApiError }
4
4
  import { N as devReviewHome } from "./s3-config-CjcLyFV5.js";
5
5
  import { n as readBuildCommit } from "./package-paths-BkeQsdaw.js";
6
6
  import { b as selectReviewInstance, u as REVIEW_INSTANCE_ENV } from "./server-discovery-DeVTdcqK.js";
7
- import { n as reviewSessionAgent } from "./review-telemetry-AZ0imsmJ.js";
8
- import { a as REVIEW_VIA_HEADER, i as REVIEW_THREAD_HEADER, n as REVIEW_AGENT_HEADER, t as ASK_THREAD_ENV } from "./request-origin-icXPv6jh.js";
7
+ import { n as reviewSessionAgent } from "./review-telemetry-Vnb2zKyn.js";
8
+ import { a as REVIEW_VIA_HEADER, i as REVIEW_THREAD_HEADER, n as REVIEW_AGENT_HEADER, t as ASK_THREAD_ENV } from "./request-origin-BxaWyOpO.js";
9
9
  import { i as publicTool, n as RECOVERY, r as callPublicTool, t as toolFailure } from "./tool-failure-CQhyLht3.js";
10
10
  import { existsSync } from "node:fs";
11
11
  import path from "node:path";
@@ -0,0 +1,2 @@
1
+ import { n as runReviewAgentCli } from "./agent-cli-CFX3bHJV.js";
2
+ export { runReviewAgentCli };
@@ -1 +1 @@
1
- {"version":"0.3.5-preview.20261021.131","commit":"6bf7b672e0c4d566b582d555044a624620c6311f","dirty":true,"builtAt":"2026-10-21T16:51:06.481Z"}
1
+ {"version":"0.3.5-preview.20261021.133","commit":"6c2c2de6384ef9b8d4f9d6a3c63d9cbbcee0ebe8","dirty":true,"builtAt":"2026-10-21T19:05:08.496Z"}
@@ -8,11 +8,11 @@ import { C as s3ProfileSchema, E as writeTraceConfigFile, N as devReviewHome, P
8
8
  import { t as errorMessage } from "./error-message-OtiDonty.js";
9
9
  import { i as readReviewPackageVersion, r as readBuildInfo, t as findReviewPackageRoot } from "./package-paths-BkeQsdaw.js";
10
10
  import { a as readReviewServerHealth, b as selectReviewInstance, c as reviewServerStateDir, d as ReviewInstanceUnavailableError, f as clearDefaultReviewInstance, g as requireHealthyReviewDesktop, h as readDefaultReviewInstance, i as readReviewServerDiscovery, l as serverNotReady, m as healthyReviewInstance, p as desktopUsesHome, t as DESKTOP_TAKEOVER_SIGNAL, u as REVIEW_INSTANCE_ENV, x as writeDefaultReviewInstance, y as reviewInstanceUnavailable } from "./server-discovery-DeVTdcqK.js";
11
- import { t as ReviewTelemetry } from "./review-telemetry-AZ0imsmJ.js";
12
- import { t as fuzzyRank } from "./fuzzy-match-D_x3wumC.js";
13
- import { _ as isInstallTarget, a as isOwnedShim, f as windowsInstallerCommand, g as ALL_INSTALL_TARGETS, h as detectConnectAgents, m as connectAgent, o as pathShimPath, p as scanLegacySkills, t as aliasInstallationToAccount, v as connectPrompts } from "./account-alias-CisiYUZT.js";
11
+ import { t as ReviewTelemetry } from "./review-telemetry-Vnb2zKyn.js";
12
+ import { t as fuzzyRank } from "./fuzzy-match-SsirV9Ap.js";
13
+ import { _ as isInstallTarget, a as isOwnedShim, f as windowsInstallerCommand, g as ALL_INSTALL_TARGETS, h as detectConnectAgents, m as connectAgent, o as pathShimPath, p as scanLegacySkills, t as aliasInstallationToAccount, v as connectPrompts } from "./account-alias-BRRpaO9U.js";
14
14
  import { n as focusReviewDesktop, r as runReviewAppLaunch } from "./review-app-launcher-DZvG5ZAh.js";
15
- import { t as reviewAgentCliHelp } from "./agent-cli-TJ7_5_B3.js";
15
+ import { t as reviewAgentCliHelp } from "./agent-cli-CFX3bHJV.js";
16
16
  import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
17
17
  import { realpath } from "node:fs/promises";
18
18
  import path from "node:path";
@@ -4355,7 +4355,7 @@ async function runReviewCli(input) {
4355
4355
  process.once("SIGINT", stop);
4356
4356
  process.once("SIGTERM", stop);
4357
4357
  process.once(DESKTOP_TAKEOVER_SIGNAL, takeover);
4358
- const { HeadlessServerBusyError, runHeadlessServer } = await import("./headless-host-BEe-aFtB.js");
4358
+ const { HeadlessServerBusyError, runHeadlessServer } = await import("./headless-host-DN6rrMrK.js");
4359
4359
  try {
4360
4360
  await runHeadlessServer({
4361
4361
  stateDir,
@@ -4420,7 +4420,7 @@ async function runReviewCli(input) {
4420
4420
  if (!existsSync(path.join(stateDir, "review-api.db"))) throw new Error(`No saved reviews in ${stateDir}, so there is no server id to reset. Check --state-dir.`);
4421
4421
  const inUse = `A Whiteboard server is using ${stateDir}. Stop it first, then run whiteboard server reset-id again.`;
4422
4422
  if (await desktopUsesHome(stateDir, env)) return refuse(inUse, options.json);
4423
- const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-Dbyidrcr.js"), import("./headless-host-BEe-aFtB.js")]);
4423
+ const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-Dbyidrcr.js"), import("./headless-host-DN6rrMrK.js")]);
4424
4424
  const reset = await withHeadlessServerLock(await realpath(stateDir), async () => {
4425
4425
  const local = await openReviewProfile(stateDir, { manageWorkspaces: false });
4426
4426
  try {
@@ -4794,7 +4794,7 @@ async function runReviewCli(input) {
4794
4794
  return commands;
4795
4795
  } });
4796
4796
  for (const [name, description] of [["api", "Call a JSON review authoring tool on the running server"], ["mcp", "Serve the JSON review authoring tools over stdio MCP"]]) configureOutput(program.command(name).description(description).argument("[args...]", "tool name and JSON input").allowUnknownOption().allowExcessArguments().helpOption(false).passThroughOptions().addHelpText("after", `\n${reviewAgentCliHelp}`), "plain").action(async (args) => {
4797
- const { runReviewAgentCli } = await import("./agent-cli-BglNRDZN.js");
4797
+ const { runReviewAgentCli } = await import("./agent-cli-D2P4EoAk.js");
4798
4798
  state.exitCode = await runReviewAgentCli({
4799
4799
  ...input,
4800
4800
  env: authoringEnv(),
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ async function runCli(effectivePath) {
17
17
  `);
18
18
  return 1;
19
19
  }
20
- const { runReviewCli } = await import("./cli-runner-cRY5NOno.js");
20
+ const { runReviewCli } = await import("./cli-runner-nE62FMPA.js");
21
21
  return runReviewCli({
22
22
  argv,
23
23
  cliPaths: {
@@ -1,7 +1,7 @@
1
1
  import "./review-home-paths-BwH08ihj.js";
2
2
  import { N as devReviewHome } from "./s3-config-CjcLyFV5.js";
3
3
  import { n as askAgentIds } from "./thread-state-BPpvKFwQ.js";
4
- import { t as ASK_THREAD_ENV } from "./request-origin-icXPv6jh.js";
4
+ import { t as ASK_THREAD_ENV } from "./request-origin-BxaWyOpO.js";
5
5
  import { createRequire } from "node:module";
6
6
  import { constants } from "node:fs";
7
7
  import { access, chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
@@ -13,8 +13,8 @@ import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPac
13
13
  import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-DeVTdcqK.js";
14
14
  import { c as askPicksSchema, d as selectionMarkdown, l as askQuestionSchema, n as askAgentIds, r as askChoiceKinds, s as askPermissionsSchema, t as applyAskChange, u as AgentSelectionSchema } from "./thread-state-BPpvKFwQ.js";
15
15
  import { t as openReviewProfile } from "./profile-duqp5uPa.js";
16
- import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-icXPv6jh.js";
17
- import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-D_x3wumC.js";
16
+ import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-BxaWyOpO.js";
17
+ import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-SsirV9Ap.js";
18
18
  import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-BCquUsB3.js";
19
19
  import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-Xom8cA_u.js";
20
20
  import crypto$1, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -28,11 +28,11 @@ import { promisify } from "node:util";
28
28
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
29
29
  import { fileURLToPath, pathToFileURL } from "node:url";
30
30
  import { Writable } from "node:stream";
31
- import { createServer } from "node:http";
32
31
  import { PROTOCOL_VERSION, RequestError, client, methods } from "@agentclientprotocol/sdk";
33
32
  import { Hono } from "hono";
34
33
  import { stringify } from "smol-toml";
35
34
  import { getRequestListener } from "@hono/node-server";
35
+ import { createServer } from "node:http";
36
36
  import { streamSSE } from "hono/streaming";
37
37
  import { once } from "node:events";
38
38
  //#region src/ask/protocol.ts
@@ -1857,8 +1857,21 @@ var ReviewServerError = class extends Error {
1857
1857
  };
1858
1858
  //#endregion
1859
1859
  //#region src/server/hono-http.ts
1860
+ const H2_FORBIDDEN_HEADERS = [
1861
+ "connection",
1862
+ "keep-alive",
1863
+ "proxy-connection",
1864
+ "transfer-encoding",
1865
+ "upgrade"
1866
+ ];
1860
1867
  function createNodeRequestListener(app) {
1861
- return getRequestListener(app.fetch);
1868
+ return getRequestListener(async (request, env) => {
1869
+ const response = await app.fetch(request, env);
1870
+ if (env.incoming.httpVersionMajor !== 2 || !H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))) return response;
1871
+ const stripped = new Response(response.body, response);
1872
+ for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
1873
+ return stripped;
1874
+ });
1862
1875
  }
1863
1876
  function jsonResponse(body, status, options = {}) {
1864
1877
  const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
@@ -2623,6 +2636,27 @@ async function resolveReviewStackLayers(subject, reviews, options = {}) {
2623
2636
  };
2624
2637
  });
2625
2638
  }
2639
+ const STACKS_FRESH_MS = 6e4;
2640
+ const stackReads = /* @__PURE__ */ new WeakMap();
2641
+ function readGitHubStacks(runGitHubApi, host, repository, pullRequestNumber) {
2642
+ const reads = stackReads.get(runGitHubApi) ?? /* @__PURE__ */ new Map();
2643
+ stackReads.set(runGitHubApi, reads);
2644
+ const keyOf = (number) => `${host}/${repository}#${number}`;
2645
+ const cached = reads.get(keyOf(pullRequestNumber));
2646
+ if (cached && Date.now() - cached.at < STACKS_FRESH_MS) return cached.stacks;
2647
+ const read = {
2648
+ at: Date.now(),
2649
+ stacks: runGitHubApi(host, `repos/${repository}/stacks?pull_request=${pullRequestNumber}`).then((text) => GitHubStacksSchema.parse(JSON.parse(text)))
2650
+ };
2651
+ reads.set(keyOf(pullRequestNumber), read);
2652
+ read.stacks.then((stacks) => {
2653
+ const stack = stacks.find((candidate) => candidate.pull_requests.some((pr) => pr.number === pullRequestNumber));
2654
+ for (const pr of stack?.pull_requests ?? []) reads.set(keyOf(pr.number), read);
2655
+ }, () => {
2656
+ if (reads.get(keyOf(pullRequestNumber)) === read) reads.delete(keyOf(pullRequestNumber));
2657
+ });
2658
+ return read.stacks;
2659
+ }
2626
2660
  async function gitHubStack(subject, reviews, runGitHubApi) {
2627
2661
  const binding = subject.origin?.pullRequestUrl?.match(/^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/);
2628
2662
  if (!binding) return [];
@@ -2631,7 +2665,7 @@ async function gitHubStack(subject, reviews, runGitHubApi) {
2631
2665
  const repoKey = `https://${host}/${owner}/${repository}`;
2632
2666
  let stacks;
2633
2667
  try {
2634
- stacks = GitHubStacksSchema.parse(JSON.parse(await runGitHubApi(host, `repos/${owner}/${repository}/stacks?pull_request=${number}`)));
2668
+ stacks = await readGitHubStacks(runGitHubApi, host, `${owner}/${repository}`, pullRequestNumber);
2635
2669
  } catch {
2636
2670
  return [];
2637
2671
  }
@@ -1,2 +1,2 @@
1
- import { i as withHeadlessServerLock, n as runHeadlessServer, r as takeOverServerHome, t as HeadlessServerBusyError } from "./headless-host-CEb5d-A0.js";
1
+ import { i as withHeadlessServerLock, n as runHeadlessServer, r as takeOverServerHome, t as HeadlessServerBusyError } from "./headless-host-CG1XSd5n.js";
2
2
  export { HeadlessServerBusyError, runHeadlessServer, takeOverServerHome, withHeadlessServerLock };
@@ -687,6 +687,10 @@ const UI_TELEMETRY_EVENTS = {
687
687
  install_location: INSTALL_LOCATION
688
688
  }
689
689
  },
690
+ loopback_tls_fallback: {
691
+ event: "review_loopback_tls_fallback",
692
+ properties: { error_name: "enum_free_short" }
693
+ },
690
694
  error_burst: {
691
695
  event: "review_error_burst",
692
696
  properties: {
@@ -3,7 +3,7 @@ import "./src-DYZLCUP_.js";
3
3
  import "./review-home-paths-BwH08ihj.js";
4
4
  import { F as writeFileAtomic, N as devReviewHome, j as withFileLock, k as processIsAlive } from "./s3-config-CjcLyFV5.js";
5
5
  import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "./package-paths-BkeQsdaw.js";
6
- import "./request-origin-icXPv6jh.js";
6
+ import "./request-origin-BxaWyOpO.js";
7
7
  import { createHmac, randomUUID } from "node:crypto";
8
8
  import { z } from "zod";
9
9
  import { existsSync, readFileSync, rmSync } from "node:fs";
@@ -12,10 +12,10 @@ import { t as errorMessage } from "../error-message-OtiDonty.js";
12
12
  import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-BkeQsdaw.js";
13
13
  import { _ as reviewInstanceIdentity, u as REVIEW_INSTANCE_ENV } from "../server-discovery-DeVTdcqK.js";
14
14
  import { t as openReviewProfile } from "../profile-duqp5uPa.js";
15
- import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-AZ0imsmJ.js";
16
- import { l as REVIEW_TELEMETRY_TABS } from "../request-origin-icXPv6jh.js";
17
- import { c as removeLegacyReviewSkills, d as skipCliInstall, h as detectConnectAgents, i as finishCliInstallUpdate, l as resetCliInstall, m as connectAgent, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-CisiYUZT.js";
18
- import { C as saveDiffrSummarizer, 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 legacyModeSchema, 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-CEb5d-A0.js";
15
+ import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-Vnb2zKyn.js";
16
+ import { l as REVIEW_TELEMETRY_TABS } from "../request-origin-BxaWyOpO.js";
17
+ import { c as removeLegacyReviewSkills, d as skipCliInstall, h as detectConnectAgents, i as finishCliInstallUpdate, l as resetCliInstall, m as connectAgent, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-BRRpaO9U.js";
18
+ import { C as saveDiffrSummarizer, 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 legacyModeSchema, 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-CG1XSd5n.js";
19
19
  import { s as readQuerySchemas } from "../authoring-tools-BCquUsB3.js";
20
20
  import crypto, { createHash, randomBytes } from "node:crypto";
21
21
  import { z } from "zod";
@@ -28,8 +28,10 @@ import { pathToFileURL } from "node:url";
28
28
  import { Readable, Writable } from "node:stream";
29
29
  import { pipeline } from "node:stream/promises";
30
30
  import { gzip, gzipSync } from "node:zlib";
31
- import { createServer } from "node:http";
32
31
  import { Hono } from "hono";
32
+ import { createServer } from "node:http";
33
+ import { createSecureServer } from "node:http2";
34
+ import { createServer as createServer$1 } from "node:net";
33
35
  import { Agent, request } from "undici";
34
36
  //#region ../review-protocol/src/review-host-status.ts
35
37
  const OFFLINE = {
@@ -766,6 +768,94 @@ function createJsonReviewReporting(store, telemetry, options = {}) {
766
768
  });
767
769
  return app;
768
770
  }
771
+ //#endregion
772
+ //#region src/server/loopback-server.ts
773
+ const FIRST_BYTE_TIMEOUT_MS$1 = 1e4;
774
+ const CLOSE_GRACE_MS = 1e3;
775
+ /**
776
+ * Plain HTTP, or with `tls` one port for both: a TLS ClientHello starts with
777
+ * 0x16, a plain HTTP request never does.
778
+ */
779
+ function createLoopbackServer(listener, tls, log) {
780
+ const plain = createServer(listener);
781
+ const plainOnly = {
782
+ server: plain,
783
+ secure: false,
784
+ close: () => closeServer(plain)
785
+ };
786
+ if (!tls) return plainOnly;
787
+ let secure;
788
+ try {
789
+ secure = createSecureServer({
790
+ ...tls,
791
+ allowHTTP1: true,
792
+ maxSessionMemory: 1024
793
+ }, listener);
794
+ } catch (error) {
795
+ log?.(`TLS unavailable, serving plain HTTP only: ${String(error)}`);
796
+ return plainOnly;
797
+ }
798
+ plain.emit("listening");
799
+ const sockets = /* @__PURE__ */ new Set();
800
+ const pending = /* @__PURE__ */ new Set();
801
+ const sessions = /* @__PURE__ */ new Set();
802
+ secure.on("session", (session) => {
803
+ sessions.add(session);
804
+ session.once("close", () => sessions.delete(session));
805
+ });
806
+ secure.on("tlsClientError", (_error, socket) => socket.destroy());
807
+ const server = createServer$1((socket) => {
808
+ sockets.add(socket);
809
+ pending.add(socket);
810
+ socket.once("close", () => {
811
+ sockets.delete(socket);
812
+ pending.delete(socket);
813
+ });
814
+ socket.on("error", () => socket.destroy());
815
+ const silent = () => socket.destroy();
816
+ socket.setTimeout(FIRST_BYTE_TIMEOUT_MS$1);
817
+ socket.once("timeout", silent);
818
+ socket.once("data", (head) => {
819
+ pending.delete(socket);
820
+ socket.setTimeout(0);
821
+ socket.off("timeout", silent);
822
+ socket.pause();
823
+ socket.unshift(head);
824
+ if (head[0] === 22) secure.emit("connection", socket);
825
+ else {
826
+ plain.emit("connection", socket);
827
+ socket.resume();
828
+ }
829
+ });
830
+ });
831
+ return {
832
+ server,
833
+ secure: true,
834
+ close: async () => {
835
+ const closed = closeServer(server);
836
+ for (const socket of pending) socket.destroy();
837
+ plain.close();
838
+ for (const session of sessions) session.close();
839
+ const force = setTimeout(() => {
840
+ for (const socket of sockets) socket.destroy();
841
+ }, CLOSE_GRACE_MS);
842
+ try {
843
+ await closed;
844
+ } finally {
845
+ clearTimeout(force);
846
+ }
847
+ }
848
+ };
849
+ }
850
+ function closeServer(server) {
851
+ return new Promise((resolve, reject) => {
852
+ if (!server.listening) {
853
+ resolve();
854
+ return;
855
+ }
856
+ server.close((error) => error ? reject(error) : resolve());
857
+ });
858
+ }
769
859
  const MAX_RETRY_MS = 3e4;
770
860
  const FIRST_BYTE_TIMEOUT_MS = 1e4;
771
861
  const NO_ANSWER = `it did not answer within ${FIRST_BYTE_TIMEOUT_MS / 1e3} seconds`;
@@ -2183,15 +2273,16 @@ function createGlobalReviewServer(input) {
2183
2273
  if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
2184
2274
  return serverJson(200, await connectAgent(request.data.target, { hasShim: existsSync(pathShimPath()) }));
2185
2275
  });
2186
- const httpServer = createServer(createNodeRequestListener(app));
2276
+ const loopback = createLoopbackServer(createNodeRequestListener(app), input.tls, input.log);
2187
2277
  return {
2188
2278
  discovery,
2279
+ secure: loopback.secure,
2189
2280
  get url() {
2190
2281
  return urlForBoundPort();
2191
2282
  },
2192
2283
  listen: async () => {
2193
2284
  await migrateDiffrConfig();
2194
- boundPort = await listen(httpServer, input.port);
2285
+ boundPort = await listen(loopback.server, input.port);
2195
2286
  discovery.url = urlForBoundPort();
2196
2287
  for (const discoveryPath of discoveryPaths) await writePrivateJsonAtomic(discoveryPath, discovery);
2197
2288
  },
@@ -2202,7 +2293,7 @@ function createGlobalReviewServer(input) {
2202
2293
  for (const discoveryPath of discoveryPaths) await removeMatchingDiscovery(discoveryPath, discovery);
2203
2294
  relay.close();
2204
2295
  openWatchdog.dispose();
2205
- await closeHttpServer(httpServer);
2296
+ await loopback.close();
2206
2297
  await gateway.close();
2207
2298
  await telemetry.shutdown(1500);
2208
2299
  },
@@ -2259,15 +2350,6 @@ function listen(server, port) {
2259
2350
  });
2260
2351
  });
2261
2352
  }
2262
- function closeHttpServer(server) {
2263
- return new Promise((resolve, reject) => {
2264
- if (!server.listening) {
2265
- resolve();
2266
- return;
2267
- }
2268
- server.close((error) => error ? reject(error) : resolve());
2269
- });
2270
- }
2271
2353
  async function removeMatchingDiscovery(filePath, discovery) {
2272
2354
  try {
2273
2355
  const current = JSON.parse(await readFile(filePath, "utf8"));
@@ -2287,6 +2369,7 @@ async function runDesktopHost(env = process.env) {
2287
2369
  const appPid = requiredPositiveInteger(env.DEV_FAST_REVIEW_APP_PID, "DEV_FAST_REVIEW_APP_PID");
2288
2370
  const packageRoot = findReviewPackageRoot(import.meta.url);
2289
2371
  const toolingRoot = env.DEV_FAST_REVIEW_TOOLING_ROOT || packageRoot;
2372
+ const tls = takeLoopbackTls(env);
2290
2373
  const telemetryEnv = { ...env };
2291
2374
  delete telemetryEnv.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
2292
2375
  const telemetry = ReviewTelemetry.fromEnv(telemetryEnv, { surface: "desktop" });
@@ -2309,7 +2392,8 @@ async function runDesktopHost(env = process.env) {
2309
2392
  token: env.DEV_FAST_REVIEW_SERVER_TOKEN,
2310
2393
  instanceId: env.DEV_FAST_REVIEW_INSTANCE_ID,
2311
2394
  identity: reviewInstanceIdentity(env),
2312
- telemetry
2395
+ telemetry,
2396
+ tls
2313
2397
  };
2314
2398
  const home = devReviewHome(env);
2315
2399
  await mkdir(home, { recursive: true });
@@ -2351,7 +2435,8 @@ async function runDesktopHost(env = process.env) {
2351
2435
  process.stdout.write(`${JSON.stringify({
2352
2436
  event: "ready",
2353
2437
  ...server.discovery,
2354
- installationId
2438
+ installationId,
2439
+ ...server.secure && { tls: true }
2355
2440
  })}\n`);
2356
2441
  const stageRustAnalyzer = () => ensureBundledRustAnalyzer({ env }).catch((error) => {
2357
2442
  const reason = error instanceof Error ? error.message : String(error);
@@ -2381,6 +2466,17 @@ async function runDesktopHost(env = process.env) {
2381
2466
  function isEnabledEnvValue(value) {
2382
2467
  return value === "1" || value?.toLowerCase() === "true";
2383
2468
  }
2469
+ /** Read once, then dropped so no child process inherits the private key. */
2470
+ function takeLoopbackTls(env) {
2471
+ const key = env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
2472
+ const cert = env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
2473
+ delete env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
2474
+ delete env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
2475
+ return key && cert ? {
2476
+ key,
2477
+ cert
2478
+ } : void 0;
2479
+ }
2384
2480
  function requiredPort(value) {
2385
2481
  const parsed = Number(value);
2386
2482
  if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("DEV_FAST_REVIEW_SERVER_PORT must be a port between 0 and 65535.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.fast/whiteboard",
3
- "version": "0.3.5-preview.20261021.131",
3
+ "version": "0.3.5-preview.20261021.133",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -88,7 +88,7 @@
88
88
  "repo": "devdotfast/review",
89
89
  "docs": "packages/review/instructions/authoring.md"
90
90
  },
91
- "gitHead": "6bf7b672e0c4d566b582d555044a624620c6311f",
91
+ "gitHead": "6c2c2de6384ef9b8d4f9d6a3c63d9cbbcee0ebe8",
92
92
  "scripts": {
93
93
  "prebuild": "pnpm run build:workspace-deps",
94
94
  "build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
@@ -78,6 +78,58 @@ export async function resolveReviewStackLayers(
78
78
  });
79
79
  }
80
80
 
81
+ type GitHubStacks = z.infer<typeof GitHubStacksSchema>;
82
+
83
+ // One GitHub answer serves every pull request in a stack for a minute.
84
+ const STACKS_FRESH_MS = 60_000;
85
+
86
+ const stackReads = new WeakMap<
87
+ RunGitHubApi,
88
+ Map<string, { at: number; stacks: Promise<GitHubStacks> }>
89
+ >();
90
+
91
+ function readGitHubStacks(
92
+ runGitHubApi: RunGitHubApi,
93
+ host: string,
94
+ repository: string,
95
+ pullRequestNumber: number,
96
+ ): Promise<GitHubStacks> {
97
+ const reads = stackReads.get(runGitHubApi) ?? new Map();
98
+
99
+ stackReads.set(runGitHubApi, reads);
100
+
101
+ const keyOf = (number: number) => `${host}/${repository}#${number}`;
102
+ const cached = reads.get(keyOf(pullRequestNumber));
103
+
104
+ if (cached && Date.now() - cached.at < STACKS_FRESH_MS) return cached.stacks;
105
+
106
+ const read = {
107
+ at: Date.now(),
108
+ stacks: runGitHubApi(
109
+ host,
110
+ `repos/${repository}/stacks?pull_request=${pullRequestNumber}`,
111
+ ).then((text) => GitHubStacksSchema.parse(JSON.parse(text))),
112
+ };
113
+
114
+ reads.set(keyOf(pullRequestNumber), read);
115
+ read.stacks.then(
116
+ (stacks) => {
117
+ const stack = stacks.find((candidate) =>
118
+ candidate.pull_requests.some((pr) => pr.number === pullRequestNumber),
119
+ );
120
+
121
+ for (const pr of stack?.pull_requests ?? [])
122
+ reads.set(keyOf(pr.number), read);
123
+ },
124
+ () => {
125
+ if (reads.get(keyOf(pullRequestNumber)) === read)
126
+ reads.delete(keyOf(pullRequestNumber));
127
+ },
128
+ );
129
+
130
+ return read.stacks;
131
+ }
132
+
81
133
  async function gitHubStack(
82
134
  subject: Subject,
83
135
  reviews: readonly Candidate[],
@@ -91,16 +143,14 @@ async function gitHubStack(
91
143
  const [, host, owner, repository, number] = binding;
92
144
  const pullRequestNumber = Number(number);
93
145
  const repoKey = `https://${host}/${owner}/${repository}`;
94
- let stacks: z.infer<typeof GitHubStacksSchema>;
146
+ let stacks: GitHubStacks;
95
147
 
96
148
  try {
97
- stacks = GitHubStacksSchema.parse(
98
- JSON.parse(
99
- await runGitHubApi(
100
- host!,
101
- `repos/${owner}/${repository}/stacks?pull_request=${number}`,
102
- ),
103
- ),
149
+ stacks = await readGitHubStacks(
150
+ runGitHubApi,
151
+ host!,
152
+ `${owner}/${repository}`,
153
+ pullRequestNumber,
104
154
  );
105
155
  } catch {
106
156
  // Stack discovery must not make the Review unavailable when GitHub or
@@ -37,6 +37,7 @@ export async function runDesktopHost(
37
37
 
38
38
  const packageRoot = findReviewPackageRoot(import.meta.url);
39
39
  const toolingRoot = env.DEV_FAST_REVIEW_TOOLING_ROOT || packageRoot;
40
+ const tls = takeLoopbackTls(env);
40
41
  const telemetryEnv = { ...env };
41
42
  delete telemetryEnv.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
42
43
 
@@ -73,6 +74,7 @@ export async function runDesktopHost(
73
74
  instanceId: env.DEV_FAST_REVIEW_INSTANCE_ID,
74
75
  identity: reviewInstanceIdentity(env),
75
76
  telemetry,
77
+ tls,
76
78
  };
77
79
 
78
80
  const home = devReviewHome(env);
@@ -119,7 +121,7 @@ export async function runDesktopHost(
119
121
  });
120
122
 
121
123
  process.stdout.write(
122
- `${JSON.stringify({ event: "ready", ...server.discovery, installationId })}\n`,
124
+ `${JSON.stringify({ event: "ready", ...server.discovery, installationId, ...(server.secure && { tls: true }) })}\n`,
123
125
  );
124
126
 
125
127
  const stageRustAnalyzer = () =>
@@ -172,6 +174,17 @@ function isEnabledEnvValue(value: string | undefined): boolean {
172
174
  return value === "1" || value?.toLowerCase() === "true";
173
175
  }
174
176
 
177
+ /** Read once, then dropped so no child process inherits the private key. */
178
+ function takeLoopbackTls(env: NodeJS.ProcessEnv) {
179
+ const key = env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
180
+ const cert = env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
181
+
182
+ delete env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
183
+ delete env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
184
+
185
+ return key && cert ? { key, cert } : undefined;
186
+ }
187
+
175
188
  function requiredPort(value: string | undefined): number {
176
189
  const parsed = Number(value);
177
190
 
@@ -1,8 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { readFile, rm } from "node:fs/promises";
4
- import { type Server, createServer } from "node:http";
5
- import type { AddressInfo } from "node:net";
4
+ import type { AddressInfo, Server } from "node:net";
6
5
  import path from "node:path";
7
6
  import { pathToFileURL } from "node:url";
8
7
 
@@ -78,6 +77,7 @@ import {
78
77
  captureReviewUiEvent,
79
78
  createJsonReviewReporting,
80
79
  } from "./json-review-reporting";
80
+ import { type LoopbackTls, createLoopbackServer } from "./loopback-server";
81
81
  import { createReviewGateway } from "./review-gateway";
82
82
  import { reviewLifecycleTelemetry } from "./review-lifecycle-telemetry";
83
83
  import { ReviewOpenWatchdog } from "./review-open-watchdog";
@@ -110,6 +110,7 @@ export interface GlobalReviewServerInput {
110
110
  relay?: ReviewDesktopVerbRelay;
111
111
  /** Electron's Review crash dump directory; `/crash-reports` reads only inside it. */
112
112
  crashDumpsDir?: string;
113
+ tls?: LoopbackTls;
113
114
  onRemoteHostRestarted?(alias: string): void;
114
115
  log?(message: string): void;
115
116
  }
@@ -117,6 +118,7 @@ export interface GlobalReviewServerInput {
117
118
  export interface GlobalReviewServer {
118
119
  readonly discovery: ReviewDesktopDiscovery;
119
120
  readonly url: string;
121
+ readonly secure: boolean;
120
122
  listen(): Promise<void>;
121
123
  close(reason?: "app-exit"): Promise<void>;
122
124
  setRemoteHosts(hosts: ReviewGatewayHost[]): void;
@@ -483,16 +485,21 @@ export function createGlobalReviewServer(
483
485
  );
484
486
  });
485
487
 
486
- const httpServer = createServer(createNodeRequestListener(app));
488
+ const loopback = createLoopbackServer(
489
+ createNodeRequestListener(app),
490
+ input.tls,
491
+ input.log,
492
+ );
487
493
 
488
494
  return {
489
495
  discovery,
496
+ secure: loopback.secure,
490
497
  get url() {
491
498
  return urlForBoundPort();
492
499
  },
493
500
  listen: async () => {
494
501
  await migrateDiffrConfig();
495
- boundPort = await listen(httpServer, input.port);
502
+ boundPort = await listen(loopback.server, input.port);
496
503
  discovery.url = urlForBoundPort();
497
504
 
498
505
  for (const discoveryPath of discoveryPaths)
@@ -508,7 +515,7 @@ export function createGlobalReviewServer(
508
515
  relay.close();
509
516
  openWatchdog.dispose();
510
517
 
511
- await closeHttpServer(httpServer);
518
+ await loopback.close();
512
519
  await gateway.close();
513
520
  await telemetry.shutdown(1_500);
514
521
  },
@@ -594,18 +601,6 @@ function listen(server: Server, port: number): Promise<number> {
594
601
  });
595
602
  }
596
603
 
597
- function closeHttpServer(server: Server): Promise<void> {
598
- return new Promise((resolve, reject) => {
599
- if (!server.listening) {
600
- resolve();
601
-
602
- return;
603
- }
604
-
605
- server.close((error) => (error ? reject(error) : resolve()));
606
- });
607
- }
608
-
609
604
  async function removeMatchingDiscovery(
610
605
  filePath: string,
611
606
  discovery: ReviewDesktopDiscovery,
@@ -1,12 +1,15 @@
1
1
  import crypto from "node:crypto";
2
- import type { IncomingMessage, ServerResponse } from "node:http";
3
2
 
4
3
  import {
5
4
  type JsonValue,
6
5
  REVIEW_HOST_HEADER,
7
6
  parseJsonText,
8
7
  } from "@dev.fast/review-protocol";
9
- import { type HttpBindings, getRequestListener } from "@hono/node-server";
8
+ import {
9
+ type Http2Bindings,
10
+ type HttpBindings,
11
+ getRequestListener,
12
+ } from "@hono/node-server";
10
13
  import { REVIEW_PERSON_HEADER } from "@review/review-api/request-origin";
11
14
  import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
12
15
  import type { Hono } from "hono";
@@ -16,13 +19,36 @@ import { StreamLimitError, readBoundedStream } from "./bounded-stream.js";
16
19
  import { DEFAULT_MAX_REQUEST_BYTES, HttpJsonError } from "./http-json";
17
20
 
18
21
  export type ReviewHonoEnv = {
19
- Bindings: HttpBindings;
22
+ Bindings: HttpBindings | Http2Bindings;
20
23
  };
21
24
 
25
+ // HTTP/2 rejects connection-specific headers, such as streamSSE's chunked framing.
26
+ const H2_FORBIDDEN_HEADERS = [
27
+ "connection",
28
+ "keep-alive",
29
+ "proxy-connection",
30
+ "transfer-encoding",
31
+ "upgrade",
32
+ ];
33
+
22
34
  export function createNodeRequestListener(
23
35
  app: Hono<ReviewHonoEnv>,
24
- ): (request: IncomingMessage, response: ServerResponse) => Promise<void> {
25
- return getRequestListener(app.fetch);
36
+ ): ReturnType<typeof getRequestListener> {
37
+ return getRequestListener(async (request, env) => {
38
+ const response = await app.fetch(request, env);
39
+
40
+ if (
41
+ env.incoming.httpVersionMajor !== 2 ||
42
+ !H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))
43
+ )
44
+ return response;
45
+
46
+ const stripped = new Response(response.body, response);
47
+
48
+ for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
49
+
50
+ return stripped;
51
+ });
26
52
  }
27
53
 
28
54
  export function jsonResponse<T>(
@@ -0,0 +1,146 @@
1
+ import {
2
+ type IncomingMessage,
3
+ type ServerResponse,
4
+ createServer,
5
+ } from "node:http";
6
+ import {
7
+ type Http2ServerRequest,
8
+ type Http2ServerResponse,
9
+ type ServerHttp2Session,
10
+ createSecureServer,
11
+ } from "node:http2";
12
+ import {
13
+ type Server as NetServer,
14
+ type Socket,
15
+ createServer as createNetServer,
16
+ } from "node:net";
17
+
18
+ export interface LoopbackTls {
19
+ key: string;
20
+ cert: string;
21
+ }
22
+
23
+ export interface LoopbackServer {
24
+ server: NetServer;
25
+ secure: boolean;
26
+ close(): Promise<void>;
27
+ }
28
+
29
+ type Listener = (
30
+ request: IncomingMessage | Http2ServerRequest,
31
+ response: ServerResponse | Http2ServerResponse,
32
+ ) => Promise<void>;
33
+
34
+ const FIRST_BYTE_TIMEOUT_MS = 10_000;
35
+
36
+ const CLOSE_GRACE_MS = 1_000;
37
+
38
+ /**
39
+ * Plain HTTP, or with `tls` one port for both: a TLS ClientHello starts with
40
+ * 0x16, a plain HTTP request never does.
41
+ */
42
+ export function createLoopbackServer(
43
+ listener: Listener,
44
+ tls?: LoopbackTls,
45
+ log?: (message: string) => void,
46
+ ): LoopbackServer {
47
+ const plain = createServer(listener);
48
+
49
+ const plainOnly = {
50
+ server: plain,
51
+ secure: false,
52
+ close: () => closeServer(plain),
53
+ };
54
+
55
+ if (!tls) return plainOnly;
56
+
57
+ let secure: ReturnType<typeof createSecureServer>;
58
+
59
+ try {
60
+ secure = createSecureServer(
61
+ // In MB. At Node's 10 MB default, queued large responses reset streams.
62
+ { ...tls, allowHTTP1: true, maxSessionMemory: 1_024 },
63
+ listener,
64
+ );
65
+ } catch (error) {
66
+ log?.(`TLS unavailable, serving plain HTTP only: ${String(error)}`);
67
+
68
+ return plainOnly;
69
+ }
70
+
71
+ // Sniffed sockets bypass plain.listen(), which starts its idle and timeout tracking.
72
+ plain.emit("listening");
73
+
74
+ const sockets = new Set<Socket>();
75
+ const pending = new Set<Socket>();
76
+ const sessions = new Set<ServerHttp2Session>();
77
+
78
+ secure.on("session", (session) => {
79
+ sessions.add(session);
80
+ session.once("close", () => sessions.delete(session));
81
+ });
82
+ secure.on("tlsClientError", (_error, socket) => socket.destroy());
83
+
84
+ const server = createNetServer((socket) => {
85
+ sockets.add(socket);
86
+ pending.add(socket);
87
+ socket.once("close", () => {
88
+ sockets.delete(socket);
89
+ pending.delete(socket);
90
+ });
91
+ socket.on("error", () => socket.destroy());
92
+
93
+ const silent = () => socket.destroy();
94
+
95
+ socket.setTimeout(FIRST_BYTE_TIMEOUT_MS);
96
+ socket.once("timeout", silent);
97
+ socket.once("data", (head: Buffer) => {
98
+ pending.delete(socket);
99
+ socket.setTimeout(0);
100
+ socket.off("timeout", silent);
101
+ socket.pause();
102
+ socket.unshift(head);
103
+
104
+ if (head[0] === 0x16) secure.emit("connection", socket);
105
+ else {
106
+ plain.emit("connection", socket);
107
+ socket.resume();
108
+ }
109
+ });
110
+ });
111
+
112
+ return {
113
+ server,
114
+ secure: true,
115
+ close: async () => {
116
+ const closed = closeServer(server);
117
+
118
+ for (const socket of pending) socket.destroy();
119
+ plain.close();
120
+
121
+ for (const session of sessions) session.close();
122
+
123
+ const force = setTimeout(() => {
124
+ for (const socket of sockets) socket.destroy();
125
+ }, CLOSE_GRACE_MS);
126
+
127
+ try {
128
+ await closed;
129
+ } finally {
130
+ clearTimeout(force);
131
+ }
132
+ },
133
+ };
134
+ }
135
+
136
+ function closeServer(server: NetServer): Promise<void> {
137
+ return new Promise((resolve, reject) => {
138
+ if (!server.listening) {
139
+ resolve();
140
+
141
+ return;
142
+ }
143
+
144
+ server.close((error) => (error ? reject(error) : resolve()));
145
+ });
146
+ }
@@ -605,6 +605,10 @@ export const UI_TELEMETRY_EVENTS = {
605
605
  event: "review_app_ready",
606
606
  properties: { duration_ms: "number", install_location: INSTALL_LOCATION },
607
607
  },
608
+ loopback_tls_fallback: {
609
+ event: "review_loopback_tls_fallback",
610
+ properties: { error_name: "enum_free_short" },
611
+ },
608
612
  error_burst: {
609
613
  event: "review_error_burst",
610
614
  properties: { message_hash: "hash_hex", suppressed: "number" },
@@ -1,2 +0,0 @@
1
- import { n as runReviewAgentCli } from "./agent-cli-TJ7_5_B3.js";
2
- export { runReviewAgentCli };