@dev.fast/whiteboard 0.2.1-preview.20261007.105 → 0.2.1-preview.20261007.106

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.
@@ -0,0 +1,2 @@
1
+ import { n as runReviewAgentCli } from "./agent-cli-DRRQfHQ-.js";
2
+ export { runReviewAgentCli };
@@ -23,7 +23,7 @@ async function runReviewAgentCli(input) {
23
23
  [REVIEW_AGENT_HEADER]: reviewSessionAgent(env)
24
24
  };
25
25
  if (mode === "mcp") {
26
- const { serveReviewMcp } = await import("./mcp-BF2_RoAR.js");
26
+ const { serveReviewMcp } = await import("./mcp-BAE5rMgB.js");
27
27
  await serveReviewMcp(async (key, agentKind) => {
28
28
  const connected = await connectReviewInstance(key ? {
29
29
  ...env,
@@ -1,4 +1,4 @@
1
- import { g as activityUpdateSchema, h as activityEndSchema, i as commandSchema, m as activityBeginSchema, n as uploadSchema } from "./local-data-B_Kfm3Id.js";
1
+ import { g as activityUpdateSchema, h as activityEndSchema, i as commandSchema, m as activityBeginSchema, n as uploadSchema } from "./local-data-D-EoJfPx.js";
2
2
  import { x as publishedEditSchema, y as pathTargetSchema } from "./stored-document-migration-T9Uj5MkN.js";
3
3
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
4
4
  import { t as findReviewPackageRoot } from "./package-paths-B6-zxvIO.js";
@@ -1 +1 @@
1
- {"version":"0.2.1-preview.20261007.105","commit":"0550109e691b4d3d8e019f913d84fc4d5a067623","dirty":true,"builtAt":"2026-10-07T17:47:46.681Z"}
1
+ {"version":"0.2.1-preview.20261007.106","commit":"e4eca7ac75d6e6ed7dc027eecca4883eb67579f6","dirty":true,"builtAt":"2026-10-07T23:14:48.283Z"}
@@ -13,7 +13,7 @@ import { t as ReviewTelemetry } from "./review-telemetry-Pp4ZsVrh.js";
13
13
  import { a as isOwnedShim, f as windowsInstallerCommand, m as connectPrompts, o as pathShimPath, p as scanLegacySkills, t as aliasInstallationToAccount } from "./account-alias-DLE9XZhi.js";
14
14
  import { t as fuzzyRank } from "./fuzzy-match-BoAmcyak.js";
15
15
  import { n as readReviewServerDiscovery, o as reviewServerStateDir, r as readReviewServerHealth, s as serverNotReady } from "./server-discovery-Dbzn3w6Z.js";
16
- import { t as reviewAgentCliHelp } from "./agent-cli-DTY727wD.js";
16
+ import { t as reviewAgentCliHelp } from "./agent-cli-DRRQfHQ-.js";
17
17
  import { closeSync, existsSync, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { realpath } from "node:fs/promises";
19
19
  import path from "node:path";
@@ -4495,7 +4495,7 @@ async function runReviewCli(input) {
4495
4495
  process.once("SIGINT", stop);
4496
4496
  process.once("SIGTERM", stop);
4497
4497
  try {
4498
- const { runHeadlessServer } = await import("./headless-host-CUyloTAd.js");
4498
+ const { runHeadlessServer } = await import("./headless-host-Dd_-zkC4.js");
4499
4499
  await runHeadlessServer({
4500
4500
  stateDir,
4501
4501
  port,
@@ -4546,7 +4546,7 @@ async function runReviewCli(input) {
4546
4546
  if (problem) throw new Error(`Cannot tell whether a Whiteboard Desktop is using ${stateDir}: ${problem.message}`);
4547
4547
  const inUse = () => /* @__PURE__ */ new Error(`A Whiteboard server is using ${stateDir}. Stop it first, then run whiteboard server reset-id again.`);
4548
4548
  if (desktops.instances.some(({ discovery }) => [discovery.appPid, discovery.serverPid].some((pid) => processIsAlive(pid)))) throw inUse();
4549
- const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-BKncMvSE.js"), import("./headless-host-CUyloTAd.js")]);
4549
+ const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-Cqztt-9-.js"), import("./headless-host-Dd_-zkC4.js")]);
4550
4550
  const reset = await withHeadlessServerLock(await realpath(stateDir), async () => {
4551
4551
  const local = await openReviewProfile(stateDir, { manageWorkspaces: false });
4552
4552
  try {
@@ -4774,7 +4774,7 @@ async function runReviewCli(input) {
4774
4774
  return commands;
4775
4775
  } });
4776
4776
  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) => {
4777
- const { runReviewAgentCli } = await import("./agent-cli-Q_EpjIbV.js");
4777
+ const { runReviewAgentCli } = await import("./agent-cli-50KJgMKi.js");
4778
4778
  state.exitCode = await runReviewAgentCli({
4779
4779
  ...input,
4780
4780
  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-BApCwwRD.js");
20
+ const { runReviewCli } = await import("./cli-runner-Cwg5PeH2.js");
21
21
  return runReviewCli({
22
22
  argv,
23
23
  cliPaths: {
@@ -1,8 +1,8 @@
1
1
  import { p as isObjectValue } from "./src-X9phtB2j.js";
2
2
  import { O as withFileLock, P as writePrivateJsonAtomic } from "./s3-config-CAxhO9u_.js";
3
3
  import "./review-home-paths-6zZH-1c9.js";
4
- import { t as openReviewProfile } from "./profile-CeT6-ZWV.js";
5
- import { i as createWhiteboardCore, l as createNodeRequestListener, m as migrateDiffrConfig, n as installProcessErrorTelemetry, p as GlobalReviewDesktopVerbRelay, t as drainServerCrashReport } from "./process-error-telemetry-Gp3cCIu4.js";
4
+ import { t as openReviewProfile } from "./profile-WzZu7Spt.js";
5
+ import { i as createWhiteboardCore, l as createNodeRequestListener, m as migrateDiffrConfig, n as installProcessErrorTelemetry, p as GlobalReviewDesktopVerbRelay, t as drainServerCrashReport } from "./process-error-telemetry-mzRG988B.js";
6
6
  import { i as reviewServerDiscoveryPath, t as headlessServerLockPath } from "./server-discovery-Dbzn3w6Z.js";
7
7
  import { randomBytes, randomUUID } from "node:crypto";
8
8
  import { mkdir, realpath, rm } from "node:fs/promises";
@@ -1913,13 +1913,22 @@ const askChangeSchema = z.discriminatedUnion("type", [
1913
1913
  ids: z.array(z.string())
1914
1914
  })
1915
1915
  ]);
1916
- z.union([z.object({
1916
+ /** A line of the watch stream. A snapshot can come at any point and resets
1917
+ * the watcher; each change carries the next `seq` after the one before it. */
1918
+ const askUpdateSchema = z.union([z.object({
1917
1919
  seq: z.number().int().nonnegative(),
1918
1920
  snapshot: askThreadStateSchema
1919
1921
  }), z.object({
1920
1922
  seq: z.number().int().positive(),
1921
1923
  change: askChangeSchema
1922
1924
  })]);
1925
+ z.union([z.object({
1926
+ threadId: z.string(),
1927
+ update: askUpdateSchema
1928
+ }), z.object({
1929
+ threadId: z.string(),
1930
+ ended: z.literal(true)
1931
+ })]);
1923
1932
  /** The server and the panel both advance a thread with this, so they agree. */
1924
1933
  function applyAskChange(state, change) {
1925
1934
  switch (change.type) {
@@ -1,6 +1,6 @@
1
1
  import { a as toolResultText, s as ReviewApiError } from "./agent-client-DoaODH6I.js";
2
2
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
3
- import { n as REVIEW_STATUS_TOOL, t as authoringTools } from "./authoring-tools-ml8wRIRj.js";
3
+ import { n as REVIEW_STATUS_TOOL, t as authoringTools } from "./authoring-tools-F_q8cC7J.js";
4
4
  import { i as publicTool, n as RECOVERY, r as callPublicTool, t as toolFailure } from "./tool-failure-Djeeax6q.js";
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -1,6 +1,6 @@
1
1
  import { c as jsonString, m as isStringValue, n as isJsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
2
2
  import { a as sharedReviewId, c as verifyShareRepository, d as StreamLimitError, f as readBoundedStream, l as exportShare, m as shareIdSchema, n as ShareClient, p as parseShareLink, r as SharePreflightError, s as readShareRepository, t as ShareAuthError, u as validateShareSources } from "./client-D01H2KOV.js";
3
- import { _ as invalidateStructuralComparisons, a as inspectSnapshot, c as askAgentIds, d as askQuestionSchema, f as AgentSelectionSchema, i as commandSchema, l as askChoiceKinds, p as selectionMarkdown, r as SCRATCHPAD_ID, s as applyAskChange, u as askPicksSchema, v as diffrExecutable, y as diffrMissingError } from "./local-data-B_Kfm3Id.js";
3
+ import { _ as invalidateStructuralComparisons, a as inspectSnapshot, c as askAgentIds, d as askQuestionSchema, f as AgentSelectionSchema, i as commandSchema, l as askChoiceKinds, p as selectionMarkdown, r as SCRATCHPAD_ID, s as applyAskChange, u as askPicksSchema, v as diffrExecutable, y as diffrMissingError } from "./local-data-D-EoJfPx.js";
4
4
  import { d as REVIEW_CLIENT_REMOTE, i as parseReviewVerbRequest, r as parseReviewDesktopVerbResult, u as REVIEW_CLIENT_HEADER, v as reviewDiffrSummarizerInputSchema, y as decodeStructuralDiffEvent } from "./src-DHiDJQDy.js";
5
5
  import { C as TraceStorageDeniedError, G as readStoreAuth, H as DEFAULT_STORE_ORIGIN, U as clearStoreAuth, W as openUrlInBrowser, _ as isTraceStorageConfigured, b as selectTraceStorage, o as listReviewTraceSessions, q as runStoreLogin, s as loadReviewAgentTrace, y as resolveTraceStorage } from "./review-agent-traces-DNJFYzc8.js";
6
6
  import { $ as unionIntervals, G as selectionKey, H as comparisonKey, I as fileLineRangeSchema, J as coverageSources, N as lensSelections, Q as subtractIntervals, T as selectionReferences, U as formatAnchor, V as anchorSelection, W as resolveDiffSelection, Y as emptyCoverage, Z as scopedCoverage, h as elements, k as UNCATEGORIZED_LENS_ID, u as anchorPins } from "./stored-document-migration-T9Uj5MkN.js";
@@ -10,7 +10,7 @@ import { t as errorMessage } from "./error-message-OtiDonty.js";
10
10
  import { T as traceMachineEnabled } from "./review-home-paths-6zZH-1c9.js";
11
11
  import { i as readReviewPackageVersion, n as readBuildCommit } from "./package-paths-B6-zxvIO.js";
12
12
  import { a as REVIEW_APP_SESSION_ID_HEADER, c as sanitizeUiTelemetryEvent, i as BUNDLE_FRAME_PATTERN, l as cleanTelemetryText, r as reviewRequestOrigin, s as isReportableCleanedMessage } from "./request-origin-DlHTFCrQ.js";
13
- import { a as scratchpadAvailable, c as readQuerySchemas, i as renderInstructions, o as inspectQuerySchema, r as instructionsQuerySchema, s as queryAnchor, t as authoringTools } from "./authoring-tools-ml8wRIRj.js";
13
+ import { a as scratchpadAvailable, c as readQuerySchemas, i as renderInstructions, o as inspectQuerySchema, r as instructionsQuerySchema, s as queryAnchor, t as authoringTools } from "./authoring-tools-F_q8cC7J.js";
14
14
  import { t as fuzzyRank } from "./fuzzy-match-BoAmcyak.js";
15
15
  import { createRequire } from "node:module";
16
16
  import crypto$1, { createHash, randomUUID } from "node:crypto";
@@ -1322,6 +1322,10 @@ var AskThread = class {
1322
1322
  this.listeners.add(listener);
1323
1323
  return () => this.listeners.delete(listener);
1324
1324
  }
1325
+ /** Whether anything follows the thread, as an open panel does. */
1326
+ watched() {
1327
+ return this.listeners.size > 0;
1328
+ }
1325
1329
  /** Starts the agent and asks the first question, or loads an earlier
1326
1330
  * conversation; failures land in the state. */
1327
1331
  async open() {
@@ -2122,6 +2126,12 @@ const OFFER_TIMEOUT_MS = 3e4;
2122
2126
  /** How long after its session opens an agent may take to list its
2123
2127
  * commands, which it sends on its own rather than in the response. */
2124
2128
  const COMMANDS_WAIT_MS = 2e3;
2129
+ /** How long a thread nobody follows may sit idle before it ends: its panel
2130
+ * went with a reload, or its review's tab never came back. The conversation
2131
+ * stays saved, so opening it again starts its agent again. */
2132
+ const UNWATCHED_IDLE_MS = 6e5;
2133
+ /** How often idle threads nobody follows are looked for. */
2134
+ const SWEEP_MS = 6e4;
2125
2135
  /** What an agent offers, from a session it starts and leaves without
2126
2136
  * asking anything: with the model given, where it offers that model, since
2127
2137
  * the efforts on offer depend on it. */
@@ -2176,20 +2186,26 @@ async function offeredBy(launch, agent, cwd, model) {
2176
2186
  process.stop();
2177
2187
  }
2178
2188
  }
2179
- /** The live Ask threads of one server; they end with it. */
2189
+ /** The live Ask threads of one server; they end with it, or once idle with
2190
+ * nothing following them. */
2180
2191
  var AskThreads = class {
2181
2192
  launch;
2182
2193
  tools;
2183
2194
  limits;
2195
+ unwatchedIdleMs;
2184
2196
  threads = /* @__PURE__ */ new Map();
2197
+ /** When each thread was last seen idle with nothing following it. */
2198
+ unwatchedSince = /* @__PURE__ */ new Map();
2199
+ sweeper;
2185
2200
  /** One question to each agent at a time about what it offers with a
2186
2201
  * model. */
2187
2202
  offers = /* @__PURE__ */ new Map();
2188
2203
  mcpServers;
2189
- constructor(launch, tools = {}, limits = askThreadLimits) {
2204
+ constructor(launch, tools = {}, limits = askThreadLimits, unwatchedIdleMs = UNWATCHED_IDLE_MS) {
2190
2205
  this.launch = launch;
2191
2206
  this.tools = tools;
2192
2207
  this.limits = limits;
2208
+ this.unwatchedIdleMs = unwatchedIdleMs;
2193
2209
  this.mcpServers = tools.mcpServers ?? (() => []);
2194
2210
  }
2195
2211
  /** How the agent's sessions reach Whiteboard's tools: the MCP tools they
@@ -2206,8 +2222,35 @@ var AskThreads = class {
2206
2222
  const thread = new AskThread(this.launch, start, this.mcpServers, this.limits);
2207
2223
  this.threads.set(thread.id, thread);
2208
2224
  thread.open();
2225
+ this.sweeper ??= setInterval(() => this.sweep(), SWEEP_MS);
2226
+ this.sweeper.unref?.();
2209
2227
  return thread;
2210
2228
  }
2229
+ /** The agents answering now, in every review. */
2230
+ working() {
2231
+ return [...this.threads.values()].flatMap((thread) => {
2232
+ const { status, agentName } = thread.read();
2233
+ return status === "running" || status === "waiting" ? [{
2234
+ threadId: thread.id,
2235
+ reviewId: thread.reviewId,
2236
+ agentName
2237
+ }] : [];
2238
+ });
2239
+ }
2240
+ /** Ends the threads left idle with nothing following them for long
2241
+ * enough. One answering or waiting on a decision runs on. */
2242
+ sweep(now = Date.now()) {
2243
+ for (const [id, thread] of this.threads) {
2244
+ const { status } = thread.read();
2245
+ if (thread.watched() || status === "starting" || status === "running" || status === "waiting") {
2246
+ this.unwatchedSince.delete(id);
2247
+ continue;
2248
+ }
2249
+ const since = this.unwatchedSince.get(id) ?? now;
2250
+ this.unwatchedSince.set(id, since);
2251
+ if (now - since >= this.unwatchedIdleMs) this.close(id);
2252
+ }
2253
+ }
2211
2254
  get(id) {
2212
2255
  return this.threads.get(id);
2213
2256
  }
@@ -2225,9 +2268,12 @@ var AskThreads = class {
2225
2268
  close(id) {
2226
2269
  this.threads.get(id)?.close();
2227
2270
  this.threads.delete(id);
2271
+ this.unwatchedSince.delete(id);
2228
2272
  }
2229
2273
  closeAll() {
2230
2274
  for (const id of [...this.threads.keys()]) this.close(id);
2275
+ clearInterval(this.sweeper);
2276
+ this.sweeper = void 0;
2231
2277
  }
2232
2278
  };
2233
2279
  //#endregion
@@ -2323,45 +2369,82 @@ function resolveFileRefs(root, files, paths, touched = []) {
2323
2369
  return resolved;
2324
2370
  }
2325
2371
  /**
2326
- * NDJSON of a thread: a snapshot, then each change in `seq` order. Answer
2327
- * tokens arrive as appends, so a reply costs about what the agent sent.
2372
+ * NDJSON of several threads over one connection: for each, a snapshot, then
2373
+ * each change in `seq` order, then `ended` once it closes. A thread that
2374
+ * isn't running is `ended` at once. Answer tokens arrive as appends, so a
2375
+ * reply costs about what the agent sent.
2376
+ *
2377
+ * One stream for every open Ask of a review: a browser opens only a few
2378
+ * connections to a host, and each stream holds one for as long as it runs.
2328
2379
  */
2329
- function watchAskThread(thread, backlog = 256) {
2380
+ function watchAskThreads(threads, backlog = 256) {
2330
2381
  const encoder = new TextEncoder();
2331
- let pending = [thread.snapshot()];
2332
- let stop = () => {};
2333
- /** The thread closed; the stream ends once `pending` is sent. */
2334
- let ended = false;
2382
+ const queues = /* @__PURE__ */ new Map();
2383
+ /** Threads still running, which may send more. */
2384
+ const live = /* @__PURE__ */ new Set();
2385
+ const stops = [];
2335
2386
  /** The stream ended, so nothing more may be enqueued. */
2336
2387
  let finished = false;
2337
2388
  const flush = (controller) => {
2338
- while (pending.length && (controller.desiredSize ?? 0) > 0) controller.enqueue(encoder.encode(`${JSON.stringify(pending.shift())}\n`));
2339
- if (ended && !pending.length && !finished) {
2389
+ let sent = true;
2390
+ while (sent && (controller.desiredSize ?? 0) > 0) {
2391
+ sent = false;
2392
+ for (const queue of queues.values()) {
2393
+ const line = queue.shift();
2394
+ if (!line) continue;
2395
+ controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
2396
+ sent = true;
2397
+ }
2398
+ }
2399
+ if (!live.size && [...queues.values()].every((queue) => !queue.length) && !finished) {
2340
2400
  finished = true;
2341
2401
  controller.close();
2342
2402
  }
2343
2403
  };
2344
2404
  const body = new ReadableStream({
2345
2405
  start(controller) {
2346
- const unsubscribe = thread.subscribe((update) => {
2347
- pending.push(update);
2348
- if (pending.length > backlog) pending = [thread.snapshot()];
2349
- flush(controller);
2350
- });
2351
- const unclose = thread.onClose(() => {
2352
- ended = true;
2353
- unsubscribe();
2354
- flush(controller);
2355
- });
2356
- stop = () => {
2357
- unsubscribe();
2358
- unclose();
2359
- };
2406
+ for (const [threadId, thread] of threads) {
2407
+ const queue = [];
2408
+ queues.set(threadId, queue);
2409
+ if (!thread) {
2410
+ queue.push({
2411
+ threadId,
2412
+ ended: true
2413
+ });
2414
+ continue;
2415
+ }
2416
+ live.add(threadId);
2417
+ queue.push({
2418
+ threadId,
2419
+ update: thread.snapshot()
2420
+ });
2421
+ const unsubscribe = thread.subscribe((update) => {
2422
+ queue.push({
2423
+ threadId,
2424
+ update
2425
+ });
2426
+ if (queue.length > backlog) queue.splice(0, queue.length, {
2427
+ threadId,
2428
+ update: thread.snapshot()
2429
+ });
2430
+ flush(controller);
2431
+ });
2432
+ const unclose = thread.onClose(() => {
2433
+ live.delete(threadId);
2434
+ unsubscribe();
2435
+ queue.push({
2436
+ threadId,
2437
+ ended: true
2438
+ });
2439
+ flush(controller);
2440
+ });
2441
+ stops.push(unsubscribe, unclose);
2442
+ }
2360
2443
  flush(controller);
2361
2444
  },
2362
2445
  pull: flush,
2363
2446
  cancel() {
2364
- stop();
2447
+ for (const stop of stops) stop();
2365
2448
  }
2366
2449
  });
2367
2450
  return new Response(body, { headers: {
@@ -3091,6 +3174,9 @@ const askStartSchema = z.strictObject({
3091
3174
  bypass: z.boolean().optional()
3092
3175
  });
3093
3176
  const askPermitSchema = z.strictObject({ bypass: z.boolean() });
3177
+ /** The threads one watch follows: those of a review's open Asks. A body,
3178
+ * not the query, so there can be as many as are open. */
3179
+ const askWatchSchema = z.strictObject({ threads: z.array(z.string().min(1)) });
3094
3180
  const askOpenSchema = z.strictObject({ picks: askPicksSchema.optional() });
3095
3181
  const askChoiceSchema = z.strictObject({
3096
3182
  kind: z.enum(askChoiceKinds),
@@ -3637,6 +3723,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3637
3723
  await selectionContext(reviewId, selection, version)
3638
3724
  ].join("\n");
3639
3725
  };
3726
+ app.get("/ask/working", (context) => context.json({ agents: ask.threads.working() }));
3640
3727
  app.get("/:id/ask/agents", async (context) => context.json({ agents: await ask.agents() }));
3641
3728
  app.get("/:id/ask/agents/:agent/offer", async (context) => {
3642
3729
  const agent = z.enum(askAgentIds).parse(context.req.param("agent"));
@@ -3710,6 +3797,15 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3710
3797
  readReview(reviewId);
3711
3798
  return context.json({ threads: store.askHistory.list(reviewId) });
3712
3799
  });
3800
+ app.post("/:id/ask/watch", async (context) => {
3801
+ const reviewId = context.req.param("id");
3802
+ readReview(reviewId);
3803
+ const { threads } = askWatchSchema.parse(await readBoundedRequestJson(context.req.raw));
3804
+ return watchAskThreads(new Map(threads.map((threadId) => {
3805
+ const thread = ask.threads.get(threadId);
3806
+ return [threadId, thread?.reviewId === reviewId ? thread : void 0];
3807
+ })));
3808
+ });
3713
3809
  app.get("/:id/ask/:threadId", (context) => {
3714
3810
  const record = store.askHistory.get(context.req.param("threadId"));
3715
3811
  if (record?.reviewId !== context.req.param("id")) throw new ReviewInputError("This conversation was not found.", 404);
@@ -3767,9 +3863,6 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3767
3863
  });
3768
3864
  return context.json({ threadId: record.id });
3769
3865
  });
3770
- app.get("/:id/ask/:threadId/watch", (context) => {
3771
- return watchAskThread(readThread(context.req.param("id"), context.req.param("threadId")));
3772
- });
3773
3866
  app.post("/:id/ask/:threadId/prompt", async (context) => {
3774
3867
  const thread = readThread(context.req.param("id"), context.req.param("threadId"));
3775
3868
  const { question } = askFollowUpSchema.parse(await readBoundedRequestJson(context.req.raw, ASK_REQUEST_MAX_BYTES));
@@ -0,0 +1,2 @@
1
+ import { t as openReviewProfile } from "./profile-WzZu7Spt.js";
2
+ export { openReviewProfile };
@@ -1,4 +1,4 @@
1
- import { o as initializeReviewStoreSchema, t as openLocalReviewStore } from "./local-data-B_Kfm3Id.js";
1
+ import { o as initializeReviewStoreSchema, t as openLocalReviewStore } from "./local-data-D-EoJfPx.js";
2
2
  import { O as withFileLock } from "./s3-config-CAxhO9u_.js";
3
3
  import "./review-home-paths-6zZH-1c9.js";
4
4
  import { access, mkdir } from "node:fs/promises";
@@ -9,12 +9,12 @@ import { A as devReviewHome, P as writePrivateJsonAtomic } from "../s3-config-CA
9
9
  import { U as shellQuote, i as reviewInstanceDiscoveryPath, o as reviewLegacyDiscoveryPath } from "../review-home-paths-6zZH-1c9.js";
10
10
  import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-B6-zxvIO.js";
11
11
  import { c as reviewInstanceIdentity, t as REVIEW_INSTANCE_ENV } from "../desktop-discovery-DK2HoBy5.js";
12
- import { t as openReviewProfile } from "../profile-CeT6-ZWV.js";
12
+ import { t as openReviewProfile } from "../profile-WzZu7Spt.js";
13
13
  import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-Pp4ZsVrh.js";
14
14
  import { o as REVIEW_TELEMETRY_TABS } from "../request-origin-DlHTFCrQ.js";
15
15
  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-DLE9XZhi.js";
16
- 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-Gp3cCIu4.js";
17
- import { c as readQuerySchemas } from "../authoring-tools-ml8wRIRj.js";
16
+ 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-mzRG988B.js";
17
+ import { c as readQuerySchemas } from "../authoring-tools-F_q8cC7J.js";
18
18
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
19
19
  import { z } from "zod";
20
20
  import fs, { existsSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.fast/whiteboard",
3
- "version": "0.2.1-preview.20261007.105",
3
+ "version": "0.2.1-preview.20261007.106",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  "@agentclientprotocol/claude-agent-acp": "0.84.0",
44
44
  "@agentclientprotocol/codex-acp": "2.0.0",
45
45
  "@agentclientprotocol/sdk": "1.5.1",
46
- "@dev.fast/diffr": "0.1.14",
46
+ "@dev.fast/diffr": "0.1.15",
47
47
  "@hono/node-server": "2.0.12",
48
48
  "@img/sharp-wasm32": "0.35.5",
49
49
  "@modelcontextprotocol/sdk": "1.31.0",
@@ -72,10 +72,10 @@
72
72
  "typescript": "6.0.3",
73
73
  "vitest": "4.1.11",
74
74
  "@dev.fast/json": "0.0.0",
75
- "@dev.fast/review-share-protocol": "0.1.2",
76
- "@dev.fast/trace-core": "0.0.0",
75
+ "@dev.fast/review-protocol": "0.0.0",
77
76
  "@dev.fast/trace-protocol": "0.5.0",
78
- "@dev.fast/review-protocol": "0.0.0"
77
+ "@dev.fast/trace-core": "0.0.0",
78
+ "@dev.fast/review-share-protocol": "0.1.2"
79
79
  },
80
80
  "engines": {
81
81
  "node": ">=24 <25"
@@ -85,7 +85,7 @@
85
85
  "repo": "devdotfast/review",
86
86
  "docs": "packages/review/instructions/authoring.md"
87
87
  },
88
- "gitHead": "0550109e691b4d3d8e019f913d84fc4d5a067623",
88
+ "gitHead": "e4eca7ac75d6e6ed7dc027eecca4883eb67579f6",
89
89
  "scripts": {
90
90
  "prebuild": "pnpm run build:workspace-deps",
91
91
  "build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
@@ -255,6 +255,15 @@ export const askUpdateSchema = z.union([
255
255
 
256
256
  export type AskUpdate = z.infer<typeof askUpdateSchema>;
257
257
 
258
+ /** One line of a watch of several threads: a thread's update, or that it
259
+ * has ended. */
260
+ export const askWatchLineSchema = z.union([
261
+ z.object({ threadId: z.string(), update: askUpdateSchema }),
262
+ z.object({ threadId: z.string(), ended: z.literal(true) }),
263
+ ]);
264
+
265
+ export type AskWatchLine = z.infer<typeof askWatchLineSchema>;
266
+
258
267
  /** The server and the panel both advance a thread with this, so they agree. */
259
268
  export function applyAskChange(
260
269
  state: AskThreadState,
package/src/ask/thread.ts CHANGED
@@ -250,6 +250,11 @@ export class AskThread {
250
250
  return () => this.listeners.delete(listener);
251
251
  }
252
252
 
253
+ /** Whether anything follows the thread, as an open panel does. */
254
+ watched() {
255
+ return this.listeners.size > 0;
256
+ }
257
+
253
258
  /** Starts the agent and asks the first question, or loads an earlier
254
259
  * conversation; failures land in the state. */
255
260
  async open() {
@@ -26,6 +26,14 @@ const OFFER_TIMEOUT_MS = 30_000;
26
26
  * commands, which it sends on its own rather than in the response. */
27
27
  const COMMANDS_WAIT_MS = 2_000;
28
28
 
29
+ /** How long a thread nobody follows may sit idle before it ends: its panel
30
+ * went with a reload, or its review's tab never came back. The conversation
31
+ * stays saved, so opening it again starts its agent again. */
32
+ const UNWATCHED_IDLE_MS = 10 * 60_000;
33
+
34
+ /** How often idle threads nobody follows are looked for. */
35
+ const SWEEP_MS = 60_000;
36
+
29
37
  /** What an agent offers, from a session it starts and leaves without
30
38
  * asking anything: with the model given, where it offers that model, since
31
39
  * the efforts on offer depend on it. */
@@ -144,9 +152,20 @@ export type AskToolsReach =
144
152
  | { kind: "cli"; command: string }
145
153
  | undefined;
146
154
 
147
- /** The live Ask threads of one server; they end with it. */
155
+ /** An agent answering in Ask, which ending the server would stop. */
156
+ export interface AskWorkingAgent {
157
+ threadId: string;
158
+ reviewId: string;
159
+ agentName: string;
160
+ }
161
+
162
+ /** The live Ask threads of one server; they end with it, or once idle with
163
+ * nothing following them. */
148
164
  export class AskThreads {
149
165
  private readonly threads = new Map<string, AskThread>();
166
+ /** When each thread was last seen idle with nothing following it. */
167
+ private readonly unwatchedSince = new Map<string, number>();
168
+ private sweeper?: ReturnType<typeof setInterval>;
150
169
  /** One question to each agent at a time about what it offers with a
151
170
  * model. */
152
171
  private readonly offers = new Map<string, Promise<AskOffer>>();
@@ -157,6 +176,7 @@ export class AskThreads {
157
176
  private readonly launch: AskAgentLauncher,
158
177
  private readonly tools: AskTools = {},
159
178
  private readonly limits: AskThreadLimits = askThreadLimits,
179
+ private readonly unwatchedIdleMs = UNWATCHED_IDLE_MS,
160
180
  ) {
161
181
  this.mcpServers = tools.mcpServers ?? (() => []);
162
182
  }
@@ -184,10 +204,47 @@ export class AskThreads {
184
204
 
185
205
  this.threads.set(thread.id, thread);
186
206
  void thread.open();
207
+ this.sweeper ??= setInterval(() => this.sweep(), SWEEP_MS);
208
+ this.sweeper.unref?.();
187
209
 
188
210
  return thread;
189
211
  }
190
212
 
213
+ /** The agents answering now, in every review. */
214
+ working(): AskWorkingAgent[] {
215
+ return [...this.threads.values()].flatMap((thread) => {
216
+ const { status, agentName } = thread.read();
217
+
218
+ return status === "running" || status === "waiting"
219
+ ? [{ threadId: thread.id, reviewId: thread.reviewId, agentName }]
220
+ : [];
221
+ });
222
+ }
223
+
224
+ /** Ends the threads left idle with nothing following them for long
225
+ * enough. One answering or waiting on a decision runs on. */
226
+ sweep(now = Date.now()) {
227
+ for (const [id, thread] of this.threads) {
228
+ const { status } = thread.read();
229
+
230
+ if (
231
+ thread.watched() ||
232
+ status === "starting" ||
233
+ status === "running" ||
234
+ status === "waiting"
235
+ ) {
236
+ this.unwatchedSince.delete(id);
237
+ continue;
238
+ }
239
+
240
+ const since = this.unwatchedSince.get(id) ?? now;
241
+
242
+ this.unwatchedSince.set(id, since);
243
+
244
+ if (now - since >= this.unwatchedIdleMs) this.close(id);
245
+ }
246
+ }
247
+
191
248
  get(id: string) {
192
249
  return this.threads.get(id);
193
250
  }
@@ -211,9 +268,12 @@ export class AskThreads {
211
268
  close(id: string) {
212
269
  this.threads.get(id)?.close();
213
270
  this.threads.delete(id);
271
+ this.unwatchedSince.delete(id);
214
272
  }
215
273
 
216
274
  closeAll() {
217
275
  for (const id of [...this.threads.keys()]) this.close(id);
276
+ clearInterval(this.sweeper);
277
+ this.sweeper = undefined;
218
278
  }
219
279
  }
package/src/ask/watch.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AskUpdate } from "@review/ask/thread-state.js";
1
+ import type { AskUpdate, AskWatchLine } from "@review/ask/thread-state.js";
2
2
 
3
3
  /** What a watch stream needs from a thread. */
4
4
  export interface AskWatchable {
@@ -7,35 +7,55 @@ export interface AskWatchable {
7
7
  onClose(closed: () => void): () => void;
8
8
  }
9
9
 
10
- /** Changes a slow reader may fall behind by before its backlog is replaced
11
- * with one snapshot, which bounds the memory a stalled panel holds. */
10
+ /** Changes a slow reader may fall behind by on one thread before that
11
+ * thread's backlog is replaced with one snapshot, which bounds the memory a
12
+ * stalled panel holds. */
12
13
  export const ASK_WATCH_BACKLOG = 256;
13
14
 
14
15
  /**
15
- * NDJSON of a thread: a snapshot, then each change in `seq` order. Answer
16
- * tokens arrive as appends, so a reply costs about what the agent sent.
16
+ * NDJSON of several threads over one connection: for each, a snapshot, then
17
+ * each change in `seq` order, then `ended` once it closes. A thread that
18
+ * isn't running is `ended` at once. Answer tokens arrive as appends, so a
19
+ * reply costs about what the agent sent.
20
+ *
21
+ * One stream for every open Ask of a review: a browser opens only a few
22
+ * connections to a host, and each stream holds one for as long as it runs.
17
23
  */
18
- export function watchAskThread(
19
- thread: AskWatchable,
24
+ export function watchAskThreads(
25
+ threads: ReadonlyMap<string, AskWatchable | undefined>,
20
26
  backlog = ASK_WATCH_BACKLOG,
21
27
  ): Response {
22
28
  const encoder = new TextEncoder();
23
- let pending: AskUpdate[] = [thread.snapshot()];
24
- let stop = () => {};
25
-
26
- /** The thread closed; the stream ends once `pending` is sent. */
27
- let ended = false;
29
+ const queues = new Map<string, AskWatchLine[]>();
30
+ /** Threads still running, which may send more. */
31
+ const live = new Set<string>();
32
+ const stops: (() => void)[] = [];
28
33
  /** The stream ended, so nothing more may be enqueued. */
29
34
  let finished = false;
30
35
 
36
+ // A line from each thread in turn, so one busy thread can't hold back
37
+ // the others.
31
38
  const flush = (controller: ReadableStreamDefaultController<Uint8Array>) => {
32
- while (pending.length && (controller.desiredSize ?? 0) > 0)
33
- controller.enqueue(
34
- encoder.encode(`${JSON.stringify(pending.shift())}\n`),
35
- );
39
+ let sent = true;
40
+
41
+ while (sent && (controller.desiredSize ?? 0) > 0) {
42
+ sent = false;
43
+
44
+ for (const queue of queues.values()) {
45
+ const line = queue.shift();
46
+
47
+ if (!line) continue;
48
+ controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
49
+ sent = true;
50
+ }
51
+ }
36
52
 
37
- // A closed thread sends nothing more; its watchers hear it end.
38
- if (ended && !pending.length && !finished) {
53
+ // Every thread has closed and said so; the stream ends.
54
+ if (
55
+ !live.size &&
56
+ [...queues.values()].every((queue) => !queue.length) &&
57
+ !finished
58
+ ) {
39
59
  finished = true;
40
60
  controller.close();
41
61
  }
@@ -43,30 +63,46 @@ export function watchAskThread(
43
63
 
44
64
  const body = new ReadableStream<Uint8Array>({
45
65
  start(controller) {
46
- const unsubscribe = thread.subscribe((update) => {
47
- pending.push(update);
66
+ for (const [threadId, thread] of threads) {
67
+ const queue: AskWatchLine[] = [];
68
+
69
+ queues.set(threadId, queue);
70
+
71
+ if (!thread) {
72
+ queue.push({ threadId, ended: true });
73
+ continue;
74
+ }
75
+
76
+ live.add(threadId);
77
+ queue.push({ threadId, update: thread.snapshot() });
78
+
79
+ const unsubscribe = thread.subscribe((update) => {
80
+ queue.push({ threadId, update });
48
81
 
49
- // The snapshot already holds every change it would replay.
50
- if (pending.length > backlog) pending = [thread.snapshot()];
51
- flush(controller);
52
- });
82
+ // The snapshot already holds every change it would replay.
83
+ if (queue.length > backlog)
84
+ queue.splice(0, queue.length, {
85
+ threadId,
86
+ update: thread.snapshot(),
87
+ });
88
+ flush(controller);
89
+ });
53
90
 
54
- const unclose = thread.onClose(() => {
55
- ended = true;
56
- unsubscribe();
57
- flush(controller);
58
- });
91
+ const unclose = thread.onClose(() => {
92
+ live.delete(threadId);
93
+ unsubscribe();
94
+ queue.push({ threadId, ended: true });
95
+ flush(controller);
96
+ });
59
97
 
60
- stop = () => {
61
- unsubscribe();
62
- unclose();
63
- };
98
+ stops.push(unsubscribe, unclose);
99
+ }
64
100
 
65
101
  flush(controller);
66
102
  },
67
103
  pull: flush,
68
104
  cancel() {
69
- stop();
105
+ for (const stop of stops) stop();
70
106
  },
71
107
  });
72
108
 
@@ -24,7 +24,7 @@ import {
24
24
  askQuestionSchema,
25
25
  } from "@review/ask/thread-state.js";
26
26
  import type { AskThreads } from "@review/ask/threads.js";
27
- import { watchAskThread } from "@review/ask/watch.js";
27
+ import { watchAskThreads } from "@review/ask/watch.js";
28
28
  import { fuzzyRank } from "@review/fuzzy-match.js";
29
29
  import { resolveReviewStackLayers } from "@review/review-stack.js";
30
30
  import { readBoundedRequestJson } from "@review/server/hono-http.js";
@@ -91,6 +91,10 @@ const askStartSchema = z.strictObject({
91
91
 
92
92
  const askPermitSchema = z.strictObject({ bypass: z.boolean() });
93
93
 
94
+ /** The threads one watch follows: those of a review's open Asks. A body,
95
+ * not the query, so there can be as many as are open. */
96
+ const askWatchSchema = z.strictObject({ threads: z.array(z.string().min(1)) });
97
+
94
98
  const askOpenSchema = z.strictObject({ picks: askPicksSchema.optional() });
95
99
 
96
100
  const askChoiceSchema = z.strictObject({
@@ -1171,6 +1175,11 @@ export function createReviewApi(
1171
1175
  ].join("\n");
1172
1176
  };
1173
1177
 
1178
+ // The agents answering in any review, which quitting the app would stop.
1179
+ app.get("/ask/working", (context) =>
1180
+ context.json({ agents: ask.threads.working() }),
1181
+ );
1182
+
1174
1183
  // Each agent with the models and efforts it offered last; none until
1175
1184
  // it has run.
1176
1185
  app.get("/:id/ask/agents", async (context) =>
@@ -1310,6 +1319,31 @@ export function createReviewApi(
1310
1319
  return context.json({ threads: store.askHistory.list(reviewId) });
1311
1320
  });
1312
1321
 
1322
+ // The threads of every open Ask in a review, over one connection. Before
1323
+ // the route for one thread, which would read `watch` as its id.
1324
+ app.post("/:id/ask/watch", async (context) => {
1325
+ const reviewId = context.req.param("id");
1326
+
1327
+ readReview(reviewId);
1328
+
1329
+ const { threads } = askWatchSchema.parse(
1330
+ await readBoundedRequestJson(context.req.raw),
1331
+ );
1332
+
1333
+ return watchAskThreads(
1334
+ new Map(
1335
+ threads.map((threadId) => {
1336
+ const thread = ask.threads.get(threadId);
1337
+
1338
+ return [
1339
+ threadId,
1340
+ thread?.reviewId === reviewId ? thread : undefined,
1341
+ ];
1342
+ }),
1343
+ ),
1344
+ );
1345
+ });
1346
+
1313
1347
  // A saved conversation, without starting its agent.
1314
1348
  app.get("/:id/ask/:threadId", (context) => {
1315
1349
  const record = store.askHistory.get(context.req.param("threadId"));
@@ -1390,15 +1424,6 @@ export function createReviewApi(
1390
1424
  return context.json({ threadId: record.id });
1391
1425
  });
1392
1426
 
1393
- app.get("/:id/ask/:threadId/watch", (context) => {
1394
- const thread = readThread(
1395
- context.req.param("id"),
1396
- context.req.param("threadId"),
1397
- );
1398
-
1399
- return watchAskThread(thread);
1400
- });
1401
-
1402
1427
  app.post("/:id/ask/:threadId/prompt", async (context) => {
1403
1428
  const thread = readThread(
1404
1429
  context.req.param("id"),
@@ -1547,7 +1572,8 @@ export function createReviewApi(
1547
1572
  return context.json({ ok: true });
1548
1573
  });
1549
1574
 
1550
- // Closing the panel ends the agent; the conversation stays saved.
1575
+ // Closing an Ask ends its agent; the conversation stays saved. A tab
1576
+ // switched away from or a reload leaves it running, to come back to.
1551
1577
  app.post("/:id/ask/:threadId/close", (context) => {
1552
1578
  readThread(context.req.param("id"), context.req.param("threadId"));
1553
1579
  ask.threads.close(context.req.param("threadId"));
@@ -1,2 +0,0 @@
1
- import { n as runReviewAgentCli } from "./agent-cli-DTY727wD.js";
2
- export { runReviewAgentCli };
@@ -1,2 +0,0 @@
1
- import { t as openReviewProfile } from "./profile-CeT6-ZWV.js";
2
- export { openReviewProfile };