@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

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 (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -1,21 +1,22 @@
1
- import { d as git, r as defaultBranch } from "./src-BMPuxWv4.js";
1
+ import { E as runVcs, S as resolveRepoContextSync, d as git, r as defaultBranch } from "./src-WPaQn75m.js";
2
2
  import { c as jsonString, m as isStringValue, n as isJsonObject, p as isObjectValue, u as parseJsonText } from "./src-X9phtB2j.js";
3
- 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-BN_FDxFr.js";
4
- import { a as inspectSnapshot, d as invalidateStructuralComparisons, f as diffrExecutable, i as commandSchema, p as diffrMissingError, r as SCRATCHPAD_ID } from "./local-data-DtQTIzHX.js";
5
- import { D as ReviewFeedbackSchema, E as ReviewFeedbackContactSchema, M as reviewDiffrSummarizerInputSchema, N as decodeStructuralDiffEvent, a as parseReviewVerbRequest, g as REVIEW_HOST_HEADER, i as parseReviewDesktopVerbResult, k as ReviewOnboardingRunRequestSchema } from "./src-DsM3jO8G.js";
6
- import { B as TraceStorageDeniedError, D as listReviewTraceSessions, I as isTraceStorageConfigured, O as loadReviewAgentTrace, R as resolveTraceStorage, T as traceMachineEnabled, at as openUrlInBrowser, et as shellQuote, it as clearStoreAuth, ot as readStoreAuth, rt as DEFAULT_STORE_ORIGIN, st as runStoreLogin, z as selectTraceStorage } from "./review-home-paths-Lc02WFuX.js";
7
- import { E as selectionReferences, G as comparisonKey, I as lensSelections, J as selectionKey, K as formatAnchor, M as UNCATEGORIZED_LENS_ID, Q as emptyCoverage, W as anchorSelection, Z as coverageSources, et as scopedCoverage, h as elements, j as stepSources, nt as unionIntervals, q as resolveDiffSelection, tt as subtractIntervals, u as anchorPins, z as fileLineRangeSchema } from "./stored-document-migration-CChxtFAy.js";
3
+ import { a as sharedReviewId, c as verifyShareRepository, d as validateShareSources, f as StreamLimitError, h as shareIdSchema, l as exportShare, m as parseShareLink, n as ShareClient, p as readBoundedStream, r as SharePreflightError, s as readShareRepository, t as ShareAuthError, u as referenceCommits } from "./client-lKVF_Elo.js";
4
+ import { f as textualRows, h as diffrMissingError, i as inspectSnapshot, m as diffrExecutable, p as invalidateStructuralComparisons, r as commandSchema } from "./local-data-BdqqCYUD.js";
5
+ import { N as reviewDiffrSummarizerInputSchema, O as ReviewFeedbackContactSchema, P as decodeStructuralDiffEvent, a as parseReviewVerbRequest, c as boardIconKind, i as parseReviewDesktopVerbResult, j as ReviewOnboardingRunRequestSchema, k as ReviewFeedbackSchema, l as boardTitle, m as REVIEW_CLIENT_REMOTE, p as REVIEW_CLIENT_HEADER, v as REVIEW_HOST_HEADER } from "./src-BiWNbcF_.js";
6
+ import { B as TraceStorageDeniedError, D as listReviewTraceSessions, I as isTraceStorageConfigured, O as loadReviewAgentTrace, R as resolveTraceStorage, T as traceMachineEnabled, at as readStoreAuth, et as shellQuote, it as clearStoreAuth, ot as runStoreLogin, rt as DEFAULT_STORE_ORIGIN, z as selectTraceStorage } from "./review-home-paths-D1JVitqN.js";
7
+ import { _ as scopedCoverage, h as emptyCoverage, i as anchorSelection, l as resolveDiffSelection, m as coverageSources, n as SelectionUnavailableError, o as comparisonKey, s as formatAnchor, u as selectionKey, v as subtractIntervals, y as unionIntervals } from "./lens-selection-CyJapcsl.js";
8
+ import { B as stepSources, F as selectionReferences, G as lensSelections, J as fileLineRangeSchema, V as UNCATEGORIZED_LENS_ID, _ as boardRepositoryId, f as anchorPins, g as boardLinks, x as elements } from "./stored-document-migration-dFbewRTG.js";
8
9
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
9
- import { A as withFileLock, I as writePrivateJsonAtomic, M as devReviewHome, h as TraceConfigurationError, i as isS3MockMode } from "./s3-config-DDWWMh5x.js";
10
+ import { L as writePrivateJsonAtomic, N as devReviewHome, h as TraceConfigurationError, i as isS3MockMode, j as withFileLock } from "./s3-config-BTO12tCC.js";
10
11
  import { t as errorMessage } from "./error-message-OtiDonty.js";
11
- import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPackageRoot } from "./package-paths-jMg6JxDP.js";
12
- import { _ as reviewInstanceIdentity, i as readReviewServerDiscovery, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-BVqrE1_-.js";
13
- 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-5pYr6_zM.js";
14
- import { t as openReviewProfile } from "./profile-DaqmODhc.js";
15
- 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-Zcn_CjX4.js";
16
- import { a as defaultAskPermissions, c as launchAskAgent, i as askAgents, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-bFX6wm8j.js";
17
- 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-DGSEag8y.js";
18
- import { a as headlessServerOwner, c as stopBackgroundServer } from "./background-server-WKzI_a98.js";
12
+ import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPackageRoot } from "./package-paths-Cm1tR8c4.js";
13
+ import { _ as reviewInstanceIdentity, i as readReviewServerDiscovery, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-Bwa_6Tsj.js";
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-Do5LX10I.js";
15
+ import { t as openReviewProfile } from "./profile-DcigfSms.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-DDmfnytX.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-DH9lZivo.js";
18
+ import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-Dfo0uOSj.js";
19
+ import { a as headlessServerOwner, c as stopBackgroundServer } from "./background-server-BF_lLFxY.js";
19
20
  import crypto$1, { createHash, randomBytes, randomUUID } from "node:crypto";
20
21
  import { z } from "zod";
21
22
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@@ -213,6 +214,7 @@ var AskThread = class {
213
214
  /** The number of changes so far; a snapshot carries the one it includes. */
214
215
  seq = 0;
215
216
  listeners = /* @__PURE__ */ new Set();
217
+ statusListeners = /* @__PURE__ */ new Set();
216
218
  closers = /* @__PURE__ */ new Set();
217
219
  decisions = /* @__PURE__ */ new Map();
218
220
  process;
@@ -318,11 +320,19 @@ var AskThread = class {
318
320
  snapshot: this.state
319
321
  };
320
322
  }
323
+ get status() {
324
+ return this.state.status;
325
+ }
321
326
  /** Every change after the current `seq`, in order. */
322
327
  subscribe(listener) {
323
328
  this.listeners.add(listener);
324
329
  return () => this.listeners.delete(listener);
325
330
  }
331
+ /** Calls back when the status changes, without following the thread. */
332
+ onStatusChange(listener) {
333
+ this.statusListeners.add(listener);
334
+ return () => this.statusListeners.delete(listener);
335
+ }
326
336
  /** Whether anything follows the thread, as an open panel does. */
327
337
  watched() {
328
338
  return this.listeners.size > 0;
@@ -579,7 +589,8 @@ var AskThread = class {
579
589
  const generation = ++this.generation;
580
590
  const launched = await this.launch(this.start.agent, this.start.cwd, {
581
591
  permissions: this.state.permissions,
582
- mcpServers: this.mcpServers
592
+ mcpServers: this.mcpServers,
593
+ threadId: this.id
583
594
  });
584
595
  if (this.closed || this.halted || generation !== this.generation) {
585
596
  launched.stop();
@@ -1177,6 +1188,7 @@ var AskThread = class {
1177
1188
  change
1178
1189
  };
1179
1190
  for (const listener of this.listeners) listener(update);
1191
+ if (change.type === "set" && change.status) for (const listener of this.statusListeners) listener();
1180
1192
  }
1181
1193
  };
1182
1194
  //#endregion
@@ -1261,13 +1273,16 @@ function cliAskTools(cliPath, selector) {
1261
1273
  }] : []
1262
1274
  ];
1263
1275
  return {
1264
- mcpServers: () => {
1276
+ mcpServers: (threadId) => {
1265
1277
  const cli = cliPath();
1266
1278
  return cli ? [{
1267
1279
  name: "whiteboard",
1268
1280
  command: process.execPath,
1269
1281
  args: [cli, "mcp"],
1270
- env: env()
1282
+ env: [...env(), {
1283
+ name: ASK_THREAD_ENV,
1284
+ value: threadId
1285
+ }]
1271
1286
  }] : [];
1272
1287
  },
1273
1288
  cli: () => {
@@ -1295,6 +1310,7 @@ var AskThreads = class {
1295
1310
  * model. */
1296
1311
  offers = /* @__PURE__ */ new Map();
1297
1312
  mcpServers;
1313
+ statusListeners = /* @__PURE__ */ new Set();
1298
1314
  constructor(launch, tools = {}, limits = askThreadLimits, unwatchedIdleMs = UNWATCHED_IDLE_MS) {
1299
1315
  this.launch = launch;
1300
1316
  this.tools = tools;
@@ -1315,6 +1331,8 @@ var AskThreads = class {
1315
1331
  open(start) {
1316
1332
  const thread = new AskThread(this.launch, start, this.mcpServers, this.limits);
1317
1333
  this.threads.set(thread.id, thread);
1334
+ thread.onStatusChange(() => this.statusChanged());
1335
+ this.statusChanged();
1318
1336
  thread.open();
1319
1337
  this.sweeper ??= setInterval(() => this.sweep(), SWEEP_MS);
1320
1338
  this.sweeper.unref?.();
@@ -1345,6 +1363,16 @@ var AskThreads = class {
1345
1363
  if (now - since >= this.unwatchedIdleMs) this.close(id);
1346
1364
  }
1347
1365
  }
1366
+ /** Calls back when a thread opens, closes or changes its status. */
1367
+ subscribeStatus(listener) {
1368
+ this.statusListeners.add(listener);
1369
+ return () => {
1370
+ this.statusListeners.delete(listener);
1371
+ };
1372
+ }
1373
+ statusChanged() {
1374
+ for (const listener of this.statusListeners) listener();
1375
+ }
1348
1376
  get(id) {
1349
1377
  return this.threads.get(id);
1350
1378
  }
@@ -1361,9 +1389,9 @@ var AskThreads = class {
1361
1389
  }
1362
1390
  close(id, save = true) {
1363
1391
  const thread = this.threads.get(id);
1364
- this.threads.delete(id);
1365
1392
  this.unwatchedSince.delete(id);
1366
1393
  thread?.close(save);
1394
+ if (this.threads.delete(id)) this.statusChanged();
1367
1395
  }
1368
1396
  closeDeletedReviews(isDeleted) {
1369
1397
  for (const [id, thread] of this.threads) if (isDeleted(thread.reviewId)) this.close(id, false);
@@ -1376,7 +1404,7 @@ var AskThreads = class {
1376
1404
  };
1377
1405
  //#endregion
1378
1406
  //#region src/server/diffr-config.ts
1379
- const execFileAsync$2 = promisify(execFile);
1407
+ const execFileAsync = promisify(execFile);
1380
1408
  let writes = Promise.resolve();
1381
1409
  let testing = false;
1382
1410
  function serialized(operation) {
@@ -1417,7 +1445,7 @@ function diffrArgv(args) {
1417
1445
  async function diffr(args, rootPath, options = {}) {
1418
1446
  const executable = diffrExecutable();
1419
1447
  try {
1420
- const operation = execFileAsync$2(executable, diffrArgv(args), {
1448
+ const operation = execFileAsync(executable, diffrArgv(args), {
1421
1449
  cwd: rootPath,
1422
1450
  maxBuffer: 16777216,
1423
1451
  signal: options.signal ?? AbortSignal.timeout(3e4),
@@ -1847,7 +1875,7 @@ function applyCorsHeaders(request, response) {
1847
1875
  const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
1848
1876
  if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
1849
1877
  }
1850
- response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`);
1878
+ response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`);
1851
1879
  response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
1852
1880
  response.headers.set("access-control-allow-private-network", "true");
1853
1881
  response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
@@ -2276,11 +2304,15 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2276
2304
  return [];
2277
2305
  }
2278
2306
  if (!file) {
2279
- const [base, head] = await Promise.all([data.file(sourcePins, "base", source.file), data.file(sourcePins, "head", source.file)]);
2280
- if (base.text !== head.text) throw new Error("Changed context file has no alignment.");
2281
- rows = Array.from({ length: base.text.split("\n").length }, (_, line) => [line, line]);
2307
+ const [base, head] = await Promise.all(["base", "head"].map((side) => data.file(sourcePins, side, source.file).then((value) => value.text, (error) => {
2308
+ if (error instanceof ReviewInputError && error.status === 404) return null;
2309
+ throw error;
2310
+ })));
2311
+ if (base === null && head === null) throw new SelectionUnavailableError("missing_file", "File is unavailable at these pins.");
2312
+ if (base !== null && head !== null && base !== head) throw new SelectionUnavailableError("no_alignment", "Changed context file has no alignment.");
2313
+ rows = textualRows(source.file, "", base?.split("\n").length ?? 0, head?.split("\n").length ?? 0);
2282
2314
  }
2283
- if (!rows) throw new Error("File has no text alignment.");
2315
+ if (!rows) throw new SelectionUnavailableError("no_alignment", "File has no text alignment.");
2284
2316
  const result = resolveDiffSelection(source, rows, file ?? { path: source.file });
2285
2317
  resolvedSelections[key] = result;
2286
2318
  return result;
@@ -2288,8 +2320,8 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2288
2320
  const resolveAvailable = async (source) => {
2289
2321
  try {
2290
2322
  return await resolve(source);
2291
- } catch {
2292
- unavailableSelections[selectionKey(source)] = "Source links need updating at these pins";
2323
+ } catch (error) {
2324
+ unavailableSelections[selectionKey(source)] = error instanceof SelectionUnavailableError ? error.reason : "other";
2293
2325
  return [];
2294
2326
  }
2295
2327
  };
@@ -2315,7 +2347,10 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2315
2347
  };
2316
2348
  }
2317
2349
  }));
2318
- await Promise.all(selectionReferences(snapshot.document, { tolerant: true }).map(async ({ source }) => {
2350
+ await Promise.all(selectionReferences(snapshot.document, {
2351
+ tolerant: true,
2352
+ repositoryId: boardRepositoryId(snapshot)
2353
+ }).map(async ({ source }) => {
2319
2354
  try {
2320
2355
  await resolveAvailable(source);
2321
2356
  } catch {}
@@ -2331,7 +2366,7 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2331
2366
  unavailable: counting || uncategorized.length ? void 0 : "All changed lines are covered by lenses"
2332
2367
  });
2333
2368
  const progress = {
2334
- complete: !counting,
2369
+ complete: !counting && !pendingSources.size,
2335
2370
  files,
2336
2371
  referenceFiles,
2337
2372
  lenses,
@@ -2388,12 +2423,11 @@ function lensReport(lenses, progress) {
2388
2423
  }
2389
2424
  //#endregion
2390
2425
  //#region src/ask/checkout-files.ts
2391
- const execFileAsync$1 = promisify(execFile);
2392
2426
  /** The checkout's files relative to its root: tracked ones, and new ones git
2393
2427
  * does not ignore. None where git cannot list them. */
2394
2428
  async function checkoutFiles(root) {
2395
2429
  try {
2396
- const { stdout } = await execFileAsync$1("git", [
2430
+ const { stdout } = await runVcs("git", [
2397
2431
  "-C",
2398
2432
  root,
2399
2433
  "ls-files",
@@ -2564,7 +2598,6 @@ function watchAskThreads(threads, backlog = 256) {
2564
2598
  }
2565
2599
  //#endregion
2566
2600
  //#region src/review-stack.ts
2567
- const execFileAsync = promisify(execFile);
2568
2601
  const GitHubStacksSchema = z.array(z.object({ pull_requests: z.array(z.object({
2569
2602
  number: z.number().int().positive(),
2570
2603
  head: z.object({ ref: z.string().min(1) })
@@ -2612,12 +2645,12 @@ async function gitHubStack(subject, reviews, runGitHubApi) {
2612
2645
  }
2613
2646
  /** The chain of reviews through this one, linked by the base each names. */
2614
2647
  async function localStack(subject, reviews, repositoryPath) {
2615
- const group = reviews.find((review) => review.reviewId === subject.reviewId)?.repositoryGroup?.key;
2648
+ const group = reviews.find((review) => review.reviewId === subject.reviewId)?.project?.id;
2616
2649
  const current = subject.origin?.branch;
2617
2650
  if (!group || !current) return [];
2618
2651
  const main = subject.origin?.baseBranch ? await defaultBranch(repositoryPath).catch(() => null) : null;
2619
2652
  const name = (ref) => ref.replace(/^origin\//, "");
2620
- const inGroup = reviews.filter((review) => review.repositoryGroup?.key === group);
2653
+ const inGroup = reviews.filter((review) => review.project?.id === group);
2621
2654
  const latest = (field, branch) => {
2622
2655
  const matches = inGroup.filter((review) => review.origin?.[field] === branch);
2623
2656
  return matches.findLast((review) => !review.trashedAt) ?? matches.at(-1);
@@ -2644,8 +2677,8 @@ async function forkPoint(repositoryPath, main, branch) {
2644
2677
  const ref = `refs/heads/${branch}`;
2645
2678
  const fork = await git(repositoryPath, [
2646
2679
  "merge-base",
2647
- main,
2648
- ref
2680
+ { rev: main },
2681
+ { rev: ref }
2649
2682
  ], { allowFailure: true });
2650
2683
  if (!fork.ok) return null;
2651
2684
  const base = fork.stdout.trim();
@@ -2655,22 +2688,17 @@ async function forkPoint(repositoryPath, main, branch) {
2655
2688
  "log",
2656
2689
  "--reverse",
2657
2690
  "--format=%s",
2658
- `${base}..${ref}`
2691
+ { rev: `${base}..${ref}` }
2659
2692
  ], { allowFailure: true })).stdout.split("\n")[0] || null
2660
2693
  };
2661
2694
  }
2662
2695
  async function defaultRunGitHubApi(host, endpoint) {
2663
- const { stdout } = await execFileAsync("gh", [
2696
+ const { stdout } = await runVcs("gh", [
2664
2697
  "api",
2665
2698
  "--hostname",
2666
- host,
2667
- endpoint
2699
+ { value: host },
2700
+ { value: endpoint }
2668
2701
  ], {
2669
- env: {
2670
- ...process.env,
2671
- GH_PROMPT_DISABLED: "1"
2672
- },
2673
- encoding: "utf8",
2674
2702
  timeout: 1e4,
2675
2703
  maxBuffer: 2097152
2676
2704
  });
@@ -2708,7 +2736,6 @@ const publishSchema = z.strictObject({
2708
2736
  /** Mounted behind local host authentication. Account credentials never enter the renderer. */
2709
2737
  function mountSharingHost(app, store, data, shared, options = {}) {
2710
2738
  const startLogin = options.login ?? runStoreLogin;
2711
- const openUrl = options.openUrl ?? openUrlInBrowser;
2712
2739
  let login = { pending: false };
2713
2740
  app.get("/sharing/account", async (context) => {
2714
2741
  const account = await readStoreAuth();
@@ -2731,9 +2758,9 @@ function mountSharingHost(app, store, data, shared, options = {}) {
2731
2758
  traces: false,
2732
2759
  stdout: discard,
2733
2760
  stderr: discard,
2734
- openUrl: async (url) => {
2735
- login.url = url;
2736
- await openUrl(url);
2761
+ openUrl: options.openUrl,
2762
+ onDeviceCode: (device) => {
2763
+ Object.assign(login, device);
2737
2764
  }
2738
2765
  }).then((code) => {
2739
2766
  login = { pending: false };
@@ -2802,8 +2829,16 @@ function mountSharingPublisher(app, store, data, options = {}) {
2802
2829
  validateShareSources(snapshot);
2803
2830
  const account = await readSharingAuth();
2804
2831
  if (!account) throw new ReviewInputError("Set DEV_REVIEW_SHARE_TOKEN for CI, or run whiteboard login before sharing.", 409);
2805
- const root = snapshot.pins ? store.repositoryPath(snapshot.pins.repositoryId) : void 0;
2806
- const repository = root ? await (options.readRepository ?? readShareRepository)(root) : void 0;
2832
+ const repositoryId = snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
2833
+ const root = repositoryId ? store.repositoryPath(repositoryId) : void 0;
2834
+ const readRepository = options.readRepository ?? readShareRepository;
2835
+ const repository = !root ? void 0 : snapshot.pins ? await readRepository(root) : await (async () => {
2836
+ try {
2837
+ return await readRepository(root);
2838
+ } catch {
2839
+ return;
2840
+ }
2841
+ })();
2807
2842
  const bundle = await exportShare({
2808
2843
  store,
2809
2844
  data,
@@ -2811,7 +2846,8 @@ function mountSharingPublisher(app, store, data, options = {}) {
2811
2846
  version: snapshot.version,
2812
2847
  repository
2813
2848
  });
2814
- const verification = root && snapshot.pins && repository ? verifyRepository(root, snapshot.pins, repository).then(() => ({ ok: true }), (error) => ({
2849
+ const commits = referenceCommits(snapshot);
2850
+ const verification = root && (snapshot.pins || commits.length) && repository ? verifyRepository(root, snapshot.pins, repository, commits).then(() => ({ ok: true }), (error) => ({
2815
2851
  ok: false,
2816
2852
  error
2817
2853
  })) : Promise.resolve({ ok: true });
@@ -2887,8 +2923,14 @@ const clip = (line) => line.trim() === "" ? "(blank line)" : line.length > 120 ?
2887
2923
  */
2888
2924
  async function anchorQuotes(before, after, read) {
2889
2925
  const key = (id, source) => `${id}\0${selectionKey(source)}`;
2890
- const known = new Set(before ? selectionReferences(before.document, { tolerant: true }).map(({ id, source }) => key(id, source)) : []);
2891
- const added = selectionReferences(after.document, { tolerant: true }).filter(({ id, source }) => !known.has(key(id, source)));
2926
+ const known = new Set(before ? selectionReferences(before.document, {
2927
+ tolerant: true,
2928
+ repositoryId: boardRepositoryId(before)
2929
+ }).map(({ id, source }) => key(id, source)) : []);
2930
+ const added = selectionReferences(after.document, {
2931
+ tolerant: true,
2932
+ repositoryId: boardRepositoryId(after)
2933
+ }).filter(({ id, source }) => !known.has(key(id, source)));
2892
2934
  return {
2893
2935
  quotes: (await Promise.all(added.slice(0, QUOTE_LIMIT).map(async ({ source }) => {
2894
2936
  try {
@@ -2913,15 +2955,18 @@ async function anchorQuotes(before, after, read) {
2913
2955
  //#endregion
2914
2956
  //#region src/review-api/document-text.ts
2915
2957
  /** A reading view of saved content, not another document format to maintain. */
2916
- function documentText(snapshot, targetId, full = false) {
2958
+ function documentText(snapshot, targetId, full = false, relations) {
2917
2959
  const target = targetId === void 0 ? void 0 : elements(snapshot.document).find((item) => item.id === targetId);
2918
2960
  if (targetId !== void 0 && !target) throw new ReviewInputError("Target not found in this version.", 404);
2919
2961
  const detailed = full || target !== void 0;
2920
- const lines = [
2921
- `# ${snapshot.title}`,
2922
- `Session ${snapshot.reviewId} · version ${snapshot.version}`,
2923
- ""
2924
- ];
2962
+ const lines = [`# ${boardTitle(snapshot.title)}`, `Session ${snapshot.reviewId} · version ${snapshot.version}`];
2963
+ if (relations && targetId === void 0) {
2964
+ const list = (boards) => boards.map((board) => `${boardTitle(board.title)} (${board.id})`).join(", ");
2965
+ if (relations.parent) lines.push(`Parent: ${list([relations.parent])}`);
2966
+ if (relations.children.length) lines.push(`Children, in order: ${list(relations.children)}`);
2967
+ if (relations.dependsOn.length) lines.push(`Depends on: ${list(relations.dependsOn)}`);
2968
+ }
2969
+ lines.push("");
2925
2970
  const write = (depth, text) => {
2926
2971
  for (const line of text.split("\n")) lines.push(" ".repeat(depth) + line);
2927
2972
  };
@@ -3349,7 +3394,7 @@ function registerOnboardingRoutes(app, store, ask, host, run = runCommand) {
3349
3394
  agent: input.agent,
3350
3395
  ...host.historyHooks({
3351
3396
  id,
3352
- reviewId,
3397
+ anchor: { board: reviewId },
3353
3398
  agent: input.agent,
3354
3399
  version: created.version,
3355
3400
  head,
@@ -3497,12 +3542,16 @@ const askDecisionSchema = z.strictObject({
3497
3542
  permissionId: z.string().min(1),
3498
3543
  optionId: z.string().min(1)
3499
3544
  });
3500
- const SCRATCHPAD_DISABLED = "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
3545
+ /** A person's save carries the whole board, which edits one at a time can
3546
+ * grow past the size of a single command. */
3547
+ const PERSON_EDIT_MAX_BYTES = 16777216;
3548
+ /** A request the gateway forwarded from another machine. */
3549
+ const remoteCaller = (context) => context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
3501
3550
  /** Both hosts mount this behind their token authentication. */
3502
3551
  function createReviewApi(store, data, open, shared, capabilities = () => ({
3503
3552
  desktopAvailable: Boolean(open),
3504
3553
  softwareMapEnabled: false
3505
- }), scratchpadEnabled = () => false, traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
3554
+ }), traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
3506
3555
  const app = new Hono();
3507
3556
  app.onError((error, context) => {
3508
3557
  if (error instanceof HttpJsonError) return context.json({ error: error.message }, error.statusCode);
@@ -3531,15 +3580,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3531
3580
  await next();
3532
3581
  };
3533
3582
  const sharedCommandSchema = z.object({ operation: z.object({ reviewId: z.string().optional() }) });
3534
- const ensureScratchpad = async (id) => {
3535
- if (open && scratchpadEnabled() && (!id || id === SCRATCHPAD_ID)) await store.ensureScratchpad();
3536
- };
3537
- const refuseDisabledScratchpad = (id) => {
3538
- if (id === SCRATCHPAD_ID && !scratchpadEnabled()) throw new ReviewInputError(SCRATCHPAD_DISABLED, 409);
3539
- };
3540
3583
  const sharedGuard = async (context, next) => {
3541
- refuseDisabledScratchpad(context.req.param("id"));
3542
- await ensureScratchpad(context.req.param("id"));
3543
3584
  const id = context.req.param("id");
3544
3585
  if (!id || !isShared(id)) return next();
3545
3586
  const query = readQuerySchemas.get.parse({ version: context.req.query("version") });
@@ -3558,23 +3599,53 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3558
3599
  if (!snapshot || version !== void 0 && version !== snapshot.version) throw new ReviewInputError("Shared review version is unavailable.", 404);
3559
3600
  return snapshot;
3560
3601
  };
3561
- const catalog = () => {
3562
- const local = store.list();
3563
- return [...scratchpadEnabled() ? local : local.filter((summary) => summary.kind !== "scratchpad"), ...shared?.list() ?? []];
3602
+ /** Adds a PR board's state, as this host last read it from GitHub. */
3603
+ const withPullRequestState = (board) => {
3604
+ const state = data?.pullRequestState(board.origin?.pullRequestUrl ?? board.pullRequestUrl);
3605
+ return state ? {
3606
+ ...board,
3607
+ pullRequestState: state
3608
+ } : board;
3564
3609
  };
3565
- app.get("/", async (context) => {
3566
- await ensureScratchpad();
3567
- return context.json(catalog());
3568
- });
3610
+ const catalog = () => [...store.list().map(withPullRequestState), ...shared?.list() ?? []];
3611
+ /** Where a board sits among its project's, and its PR's facts, for the
3612
+ * board's header. */
3613
+ const placeOf = (id) => {
3614
+ if (isShared(id) || !store.has(id)) return { siblings: [] };
3615
+ const { board, parent, siblings, children } = store.place(id);
3616
+ const url = board.origin?.pullRequestUrl;
3617
+ const state = data?.pullRequestState(url);
3618
+ const baseBranch = url && data?.pullRequestBaseBranch(url);
3619
+ const headBranch = url && data?.pullRequestHeadBranch(url);
3620
+ const place = ({ reviewId, title, target }) => ({
3621
+ reviewId,
3622
+ title,
3623
+ icon: boardIconKind(target)
3624
+ });
3625
+ const listed = (child) => {
3626
+ const childState = data?.pullRequestState(child.origin?.pullRequestUrl);
3627
+ return {
3628
+ ...place(child),
3629
+ ...child.origin?.pullRequestNumber && { pullRequestNumber: child.origin.pullRequestNumber },
3630
+ ...childState && { pullRequestState: childState }
3631
+ };
3632
+ };
3633
+ return {
3634
+ ...state && { pullRequestState: state },
3635
+ ...baseBranch && { baseBranch },
3636
+ ...headBranch && { headBranch },
3637
+ board: place(board),
3638
+ ...parent && { parent: place(parent) },
3639
+ siblings: siblings.map(listed),
3640
+ ...children?.length && { children: children.map(listed) }
3641
+ };
3642
+ };
3643
+ app.get("/", (context) => context.json(catalog()));
3569
3644
  app.get("/feedback-eligibility", (context) => context.json({ eligible: store.feedbackEligible() }));
3570
- const instructionContext = async () => ({
3571
- desktopAvailable: Boolean(open),
3572
- scratchpadEnabled: scratchpadEnabled(),
3573
- traceEnabled: await traceEnabled()
3574
- });
3645
+ const instructionContext = async () => ({ traceEnabled: await traceEnabled() });
3575
3646
  app.get("/authoring", async (context) => {
3576
3647
  const instructions = await instructionContext();
3577
- return context.json(authoringTools(scratchpadAvailable(instructions), instructions.traceEnabled));
3648
+ return context.json(authoringTools(instructions.traceEnabled));
3578
3649
  });
3579
3650
  app.get("/instructions", async (context) => {
3580
3651
  const { topic } = instructionsQuerySchema.parse(context.req.query());
@@ -3628,10 +3699,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3628
3699
  ...status(),
3629
3700
  desktopAvailable: (await capabilities()).desktopAvailable
3630
3701
  }));
3631
- app.get("/capabilities", async (context) => context.json({
3632
- ...await capabilities(),
3633
- scratchpadEnabled: scratchpadEnabled()
3634
- }));
3702
+ app.get("/capabilities", async (context) => context.json(await capabilities()));
3635
3703
  app.get("/:id/activity", (context) => {
3636
3704
  const id = context.req.param("id");
3637
3705
  readReview(id);
@@ -3653,6 +3721,9 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3653
3721
  action
3654
3722
  } : input));
3655
3723
  });
3724
+ app.post("/:id/person-edit", async (context) => context.json(await store.personEdit(context.req.param("id"), await readBoundedRequestJson(context.req.raw, PERSON_EDIT_MAX_BYTES))));
3725
+ app.post("/:id/focus", async (context) => context.json(store.focus(context.req.param("id"), await readBoundedRequestJson(context.req.raw))));
3726
+ app.post("/:id/discard", async (context) => context.json(await store.discard(context.req.param("id"))));
3656
3727
  app.get("/watch", (context) => {
3657
3728
  let input;
3658
3729
  try {
@@ -3669,6 +3740,16 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3669
3740
  let dirtyList = lists;
3670
3741
  const dirtyReviews = new Set(reviewIds);
3671
3742
  let sentList;
3743
+ const linksRevision = /* @__PURE__ */ new Map();
3744
+ const linksKey = (id) => {
3745
+ if (!data || isShared(id) || !store.has(id)) return "";
3746
+ const { outgoing, backlinks } = store.links(id);
3747
+ return JSON.stringify([...outgoing, ...backlinks].map((link) => link.pullRequestUrl && data.pullRequestState(link.pullRequestUrl, { seen: false })));
3748
+ };
3749
+ const linksKeys = new Map([...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)]));
3750
+ const placeRevision = /* @__PURE__ */ new Map();
3751
+ const placeKey = (id) => JSON.stringify(placeOf(id));
3752
+ const placeKeys = new Map([...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)]));
3672
3753
  const readReviewLine = (reviewId) => {
3673
3754
  try {
3674
3755
  return {
@@ -3677,7 +3758,9 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3677
3758
  value: {
3678
3759
  ...readReview(reviewId),
3679
3760
  activity: store.activity.read(reviewId),
3680
- coverageRevision: data?.coverageRevision ?? 0
3761
+ coverageRevision: data?.coverageRevision ?? 0,
3762
+ linksRevision: linksRevision.get(reviewId) ?? 0,
3763
+ placeRevision: placeRevision.get(reviewId) ?? 0
3681
3764
  }
3682
3765
  };
3683
3766
  } catch (error) {
@@ -3709,6 +3792,18 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3709
3792
  dirtyReviews.add(reviewId);
3710
3793
  return true;
3711
3794
  };
3795
+ /** Bumps the place of each board whose place changed. */
3796
+ const refreshPlaces = () => {
3797
+ let marked = false;
3798
+ for (const [id, key] of placeKeys) {
3799
+ const next = placeKey(id);
3800
+ if (next === key) continue;
3801
+ placeKeys.set(id, next);
3802
+ placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
3803
+ marked = markReview(id) || marked;
3804
+ }
3805
+ return marked;
3806
+ };
3712
3807
  const markLists = () => {
3713
3808
  dirtyList ||= lists;
3714
3809
  return lists;
@@ -3721,7 +3816,12 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3721
3816
  notify();
3722
3817
  }) ?? (() => {}),
3723
3818
  store.subscribe((result) => {
3724
- if (markReview(result.reviewId)) notify();
3819
+ let marked = markReview(result.reviewId);
3820
+ for (const from of store.linkingBoards(result.reviewId)) {
3821
+ linksRevision.set(from, (linksRevision.get(from) ?? 0) + 1);
3822
+ marked = markReview(from) || marked;
3823
+ }
3824
+ if (marked) notify();
3725
3825
  }),
3726
3826
  store.activity.subscribe((id) => {
3727
3827
  if (markReview(id)) notify();
@@ -3729,11 +3829,24 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3729
3829
  store.activity.subscribeWorking(() => {
3730
3830
  if (markLists()) notify();
3731
3831
  }),
3832
+ data?.subscribePullRequestStates(() => {
3833
+ let marked = markLists();
3834
+ marked = refreshPlaces() || marked;
3835
+ for (const reviewId of reviewIds) {
3836
+ const next = linksKey(reviewId);
3837
+ if (next === linksKeys.get(reviewId)) continue;
3838
+ linksKeys.set(reviewId, next);
3839
+ linksRevision.set(reviewId, (linksRevision.get(reviewId) ?? 0) + 1);
3840
+ marked = markReview(reviewId) || marked;
3841
+ }
3842
+ if (marked) notify();
3843
+ }) ?? (() => {}),
3732
3844
  shared?.subscribe(() => {
3733
3845
  if (markLists()) notify();
3734
3846
  }) ?? (() => {}),
3735
3847
  store.subscribeCatalog(() => {
3736
- if (markLists()) notify();
3848
+ const marked = refreshPlaces();
3849
+ if (markLists() || marked) notify();
3737
3850
  })
3738
3851
  ];
3739
3852
  return () => stops.forEach((stop) => stop());
@@ -3789,19 +3902,18 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3789
3902
  storage: z.enum(["s3", "hosted"]).optional(),
3790
3903
  trace: z.string().min(1).optional()
3791
3904
  });
3792
- const tracePins = (id, version) => {
3793
- const { pins } = readReview(id, version);
3794
- if (!pins) throw new ReviewInputError("This document has no source pins of its own.", 409);
3795
- return pins;
3905
+ const tracePins = async (id, version) => {
3906
+ const snapshot = readReview(id, version);
3907
+ return snapshot.pins ?? await data.documentPins(snapshot);
3796
3908
  };
3797
3909
  app.get("/:id/agent-traces", async (context) => {
3798
3910
  const query = traceQuery.parse(context.req.query());
3799
- const pins = tracePins(context.req.param("id"), query.version);
3911
+ const pins = await tracePins(context.req.param("id"), query.version);
3800
3912
  return context.json(await listPinnedTraces(store.repositoryPath(pins.repositoryId), pins, query.storage));
3801
3913
  });
3802
3914
  app.get("/:id/agent-traces/:sessionId", async (context) => {
3803
3915
  const query = traceQuery.parse(context.req.query());
3804
- const pins = tracePins(context.req.param("id"), query.version);
3916
+ const pins = await tracePins(context.req.param("id"), query.version);
3805
3917
  const result = await readStoredTrace(store.repositoryPath(pins.repositoryId), context.req.param("sessionId"), query.trace, query.storage);
3806
3918
  if (!result.ok) return context.json({
3807
3919
  ok: false,
@@ -3844,7 +3956,16 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3844
3956
  const input = z.strictObject({ path: z.string().min(1) }).parse(await readBoundedRequestJson(context.req.raw));
3845
3957
  return context.json(await data.register(input.path));
3846
3958
  });
3847
- app.post("/resources", async (context) => context.json(await data.upload(await readBoundedRequestJson(context.req.raw, 8388608))));
3959
+ app.post("/resources", async (context) => {
3960
+ const body = await readBoundedRequestJson(context.req.raw, 8388608);
3961
+ const named = z.looseObject({ repositoryPath: z.string().min(1) }).safeParse(body);
3962
+ if (!named.success) return context.json(await data.upload(body));
3963
+ const { repositoryPath, ...upload } = named.data;
3964
+ return context.json(await data.upload({
3965
+ ...upload,
3966
+ repositoryId: (await data.register(repositoryPath)).id
3967
+ }));
3968
+ });
3848
3969
  app.get("/:id/resources/:resourceId", async (context) => {
3849
3970
  const id = context.req.param("id");
3850
3971
  const snapshot = readReview(id);
@@ -3951,6 +4072,11 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3951
4072
  const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
3952
4073
  return context.json(await data.changes(pins));
3953
4074
  });
4075
+ app.get("/:id/commit/:commit", async (context) => {
4076
+ const repositoryId = boardRepositoryId(readReview(context.req.param("id")));
4077
+ if (!repositoryId) throw new ReviewInputError("This board has no repository.", 404);
4078
+ return context.json(await data.commitInfo(repositoryId, context.req.param("commit")));
4079
+ });
3954
4080
  app.get("/:id/commits", async (context) => {
3955
4081
  const input = readQuerySchemas.commits.parse(context.req.query());
3956
4082
  return context.json(await data.commits((await data.resolveSource(readReview(context.req.param("id"), input.version))).pins));
@@ -3983,20 +4109,12 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3983
4109
  head: `b/${diff.newPath}`
3984
4110
  } : void 0);
3985
4111
  return [
3986
- `Whiteboard: ${snapshot.title}`,
4112
+ `Whiteboard: ${boardTitle(snapshot.title)}`,
3987
4113
  `Session ID: ${snapshot.reviewId}`,
3988
4114
  `Version: ${snapshot.version}`,
3989
4115
  ...selection.apiSource?.commit ? [`Selected commit: ${selection.apiSource.commit}`] : [],
3990
- ...selection.apiSource?.pins ? [
3991
- `Selected repository ID: ${selection.apiSource.pins.repositoryId}`,
3992
- ...selection.apiSource.pins.base ? [`Selected base: ${selection.apiSource.pins.base}`] : [],
3993
- `Selected head: ${selection.apiSource.pins.head}`
3994
- ] : [],
3995
- ...snapshot.pins ? [
3996
- `Repository ID: ${snapshot.pins.repositoryId}`,
3997
- `Session base: ${snapshot.pins.base}`,
3998
- `Session head: ${snapshot.pins.head}`
3999
- ] : [],
4116
+ ...selection.apiSource?.pins ? [...selection.apiSource.pins.base ? [`Selected base: ${selection.apiSource.pins.base}`] : [], `Selected head: ${selection.apiSource.pins.head}`] : [],
4117
+ ...snapshot.pins ? [`Session base: ${snapshot.pins.base}`, `Session head: ${snapshot.pins.head}`] : [],
4000
4118
  `Read this version with session_get({"sessionId":"${snapshot.reviewId}","version":${snapshot.version},"full":true}).`,
4001
4119
  "",
4002
4120
  text,
@@ -4005,9 +4123,10 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4005
4123
  ].join("\n");
4006
4124
  }
4007
4125
  if (ask && data) {
4126
+ const projectOf = (reviewId) => isShared(reviewId) || !store.has(reviewId) ? void 0 : store.projectOf(reviewId);
4008
4127
  const readThread = (reviewId, threadId) => {
4009
4128
  const thread = ask.threads.get(threadId);
4010
- if (!thread || thread.reviewId !== reviewId) throw new ReviewInputError("This conversation has ended.", 404);
4129
+ if (!thread) throw new ReviewInputError("This conversation has ended.", 404);
4011
4130
  return thread;
4012
4131
  };
4013
4132
  /** Where a thread in a review saves itself: its Ask history, recorded
@@ -4057,7 +4176,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4057
4176
  const reach = await ask.threads.reach(agent, threadId);
4058
4177
  const { reviewId } = snapshot;
4059
4178
  return [
4060
- `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
4179
+ `A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
4061
4180
  checkout.live ? "Your working directory is the repository the review describes." : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
4062
4181
  "Answer the question for a staff engineer: lead with the answer and keep it short. Explain at the level of components and data flow before functions, and check each claim about the code against code you have read.",
4063
4182
  "Name files by their path from the checkout root, with a line where it helps, as in `src/app.ts:42`; the reviewer can open them from your answer.",
@@ -4108,13 +4227,17 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4108
4227
  const permissions = input.permissions ?? defaultAskPermissions(input.agent);
4109
4228
  const initialContext = await askContext(id, input.agent, snapshot, checkout, input.selection, version);
4110
4229
  assertWritable(reviewId);
4230
+ store.askHistory.markViewed(id, createdAt);
4111
4231
  const thread = ask.threads.open({
4112
4232
  id,
4113
4233
  reviewId,
4114
4234
  agent: input.agent,
4115
4235
  ...historyHooks({
4116
4236
  id,
4117
- reviewId,
4237
+ anchor: target.kind === "text" && target.anchor ? {
4238
+ board: reviewId,
4239
+ block: target.anchor.blockId
4240
+ } : { board: reviewId },
4118
4241
  agent: input.agent,
4119
4242
  version: snapshot.version,
4120
4243
  head: checkout.head,
@@ -4138,23 +4261,95 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4138
4261
  });
4139
4262
  return context.json({ threadId: thread.id });
4140
4263
  });
4264
+ /** Every project's threads for the sidebar: who runs them, where they
4265
+ * started and live, and which boards they changed. */
4266
+ const projectThreads = (reviewId) => {
4267
+ const entries = store.askHistory.listAll();
4268
+ const ids = entries.map((entry) => entry.id);
4269
+ const edited = store.boardsChangedByThreads(ids);
4270
+ const viewed = store.askHistory.viewedAt(ids);
4271
+ const boards = /* @__PURE__ */ new Map();
4272
+ const board = (id) => {
4273
+ let found = boards.get(id);
4274
+ if (!found) {
4275
+ found = store.has(id) ? store.boardFace(id) : {
4276
+ reviewId: id,
4277
+ icon: boardIconKind(void 0)
4278
+ };
4279
+ boards.set(id, found);
4280
+ }
4281
+ return found;
4282
+ };
4283
+ const project = projectOf(reviewId);
4284
+ return {
4285
+ board: reviewId,
4286
+ ...project && { project },
4287
+ threads: entries.map((entry) => {
4288
+ const anchor = entry.anchor ?? { board: reviewId };
4289
+ const status = ask.threads.get(entry.id)?.status;
4290
+ const viewedAt = viewed.get(entry.id);
4291
+ const unread = status !== "starting" && status !== "running" && status !== "waiting" && viewedAt !== void 0 && entry.updatedAt > viewedAt;
4292
+ return {
4293
+ id: entry.id,
4294
+ ...entry.projectId && { projectId: entry.projectId },
4295
+ ...store.has(anchor.board) && { place: store.boardPlace(anchor.board) },
4296
+ agent: entry.agent,
4297
+ agentName: askAgents[entry.agent].name,
4298
+ opening: entry.question ?? entry.title,
4299
+ pinned: entry.selection.target.kind === "text" && entry.selection.target.anchor !== void 0,
4300
+ ...status && { status },
4301
+ ...unread && { unread },
4302
+ updatedAt: entry.updatedAt,
4303
+ anchor: {
4304
+ board: board(anchor.board),
4305
+ ...anchor.block && { block: anchor.block }
4306
+ },
4307
+ edited: edited.get(entry.id) ?? []
4308
+ };
4309
+ })
4310
+ };
4311
+ };
4141
4312
  app.get("/:id/ask/threads", (context) => {
4142
4313
  const reviewId = context.req.param("id");
4143
4314
  readReview(reviewId);
4144
- return context.json({ threads: store.askHistory.list(reviewId) });
4315
+ if (context.req.query("watch") !== void 0) {
4316
+ let sent = "";
4317
+ return watch(() => {
4318
+ const line = JSON.stringify(projectThreads(reviewId));
4319
+ if (line === sent) return [];
4320
+ sent = line;
4321
+ return [line];
4322
+ }, (notify) => {
4323
+ const stops = [
4324
+ store.askHistory.subscribe(notify),
4325
+ ask.threads.subscribeStatus(notify),
4326
+ store.subscribe(() => notify())
4327
+ ];
4328
+ return () => stops.forEach((stop) => stop());
4329
+ });
4330
+ }
4331
+ return context.json({
4332
+ threads: store.askHistory.list(reviewId, projectOf(reviewId)),
4333
+ board: reviewId
4334
+ });
4145
4335
  });
4146
4336
  app.post("/:id/ask/watch", reviewAccess("read"), async (context) => {
4147
4337
  const reviewId = context.req.param("id");
4148
4338
  readReview(reviewId);
4149
4339
  const { threads } = askWatchSchema.parse(await readBoundedRequestJson(context.req.raw));
4150
4340
  return watchAskThreads(new Map(threads.map((threadId) => {
4151
- const thread = ask.threads.get(threadId);
4152
- return [threadId, thread?.reviewId === reviewId ? thread : void 0];
4341
+ return [threadId, ask.threads.get(threadId)];
4153
4342
  })));
4154
4343
  });
4344
+ app.get("/:id/ask/:threadId/boards", (context) => {
4345
+ const threadId = context.req.param("threadId");
4346
+ const anchor = store.askHistory.get(threadId)?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
4347
+ if (remoteCaller(context) || !anchor) throw new ReviewInputError("This conversation was not found.", 404);
4348
+ return context.json({ boards: store.boardsChangedBy(threadId) });
4349
+ });
4155
4350
  app.get("/:id/ask/:threadId", (context) => {
4156
4351
  const record = store.askHistory.get(context.req.param("threadId"));
4157
- if (record?.reviewId !== context.req.param("id")) throw new ReviewInputError("This conversation was not found.", 404);
4352
+ if (!record) throw new ReviewInputError("This conversation was not found.", 404);
4158
4353
  const target = record.selection.target;
4159
4354
  return context.json({
4160
4355
  id: record.id,
@@ -4176,18 +4371,19 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4176
4371
  app.post("/:id/ask/:threadId/open", reviewAccess("edit"), async (context) => {
4177
4372
  const reviewId = context.req.param("id");
4178
4373
  const threadId = context.req.param("threadId");
4179
- if (ask.threads.get(threadId)?.reviewId === reviewId) return context.json({ threadId });
4374
+ if (ask.threads.get(threadId)) return context.json({ threadId });
4180
4375
  const record = store.askHistory.get(threadId);
4181
4376
  const { picks } = askOpenSchema.parse(await readBoundedRequestJson(context.req.raw, void 0, {}));
4182
- if (record?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
4183
- const snapshot = readReview(reviewId, record.version);
4377
+ if (!record) throw new ReviewInputError("This conversation was not found.", 404);
4378
+ const anchor = record.anchor.board;
4379
+ const snapshot = readReview(anchor, record.version);
4184
4380
  const checkout = await data.agentCheckout(snapshot);
4185
4381
  const target = record.selection.target;
4186
4382
  const initialContext = await askContext(record.id, record.agent, snapshot, checkout, record.selection, record.version);
4187
4383
  assertWritable(reviewId);
4188
4384
  ask.threads.open({
4189
4385
  id: record.id,
4190
- reviewId,
4386
+ reviewId: anchor,
4191
4387
  agent: record.agent,
4192
4388
  cwd: checkout.rootPath,
4193
4389
  head: checkout.head,
@@ -4263,27 +4459,91 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4263
4459
  await readThread(context.req.param("id"), context.req.param("threadId")).cancel();
4264
4460
  return context.json({ ok: true });
4265
4461
  });
4462
+ app.post("/:id/ask/:threadId/viewed", (context) => {
4463
+ const threadId = context.req.param("threadId");
4464
+ if (!ask.threads.get(threadId) && !store.askHistory.get(threadId)) throw new ReviewInputError("This conversation was not found.", 404);
4465
+ store.askHistory.markViewed(threadId);
4466
+ return context.json({ ok: true });
4467
+ });
4266
4468
  app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
4267
4469
  readThread(context.req.param("id"), context.req.param("threadId"));
4268
4470
  ask.threads.close(context.req.param("threadId"), !readReview(context.req.param("id")).trashedAt);
4269
4471
  return context.json({ ok: true });
4270
4472
  });
4271
4473
  app.delete("/:id/ask/:threadId", reviewAccess("edit"), (context) => {
4272
- const reviewId = context.req.param("id");
4474
+ context.req.param("id");
4273
4475
  const threadId = context.req.param("threadId");
4274
- if (store.askHistory.get(threadId)?.reviewId !== reviewId) throw new ReviewInputError("This conversation was not found.", 404);
4275
- if (ask.threads.get(threadId)?.reviewId === reviewId) ask.threads.close(threadId);
4476
+ if (!store.askHistory.get(threadId)) throw new ReviewInputError("This conversation was not found.", 404);
4477
+ if (ask.threads.get(threadId)) ask.threads.close(threadId);
4276
4478
  store.askHistory.delete(threadId);
4277
4479
  return context.json({ ok: true });
4278
4480
  });
4279
4481
  }
4482
+ app.get("/:id/place", (context) => {
4483
+ const id = context.req.param("id");
4484
+ if (!isShared(id)) store.assertExists(id);
4485
+ return context.json(placeOf(id));
4486
+ });
4280
4487
  app.get("/:id/stack", async (context) => {
4281
4488
  const query = readQuerySchemas.get.parse(context.req.query());
4282
4489
  const id = context.req.param("id");
4283
4490
  const snapshot = readReview(id, query.version);
4284
4491
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
4285
- const layers = await resolveReviewStackLayers(snapshot, store.list(), { repositoryPath: store.repositories().find((repository) => repository.id === snapshot.pins.repositoryId)?.path });
4286
- return context.json({ layers });
4492
+ const repositoryPath = store.repositories().find((repository) => repository.id === snapshot.pins.repositoryId)?.path;
4493
+ const layers = await resolveReviewStackLayers(snapshot, store.list(), { repositoryPath });
4494
+ const trunk = layers.length > 1 && repositoryPath ? await defaultBranch(repositoryPath).catch(() => null) : null;
4495
+ return context.json({
4496
+ layers: layers.map((layer) => {
4497
+ const state = data?.pullRequestState(layer.pullRequestUrl ?? void 0);
4498
+ return state ? {
4499
+ ...layer,
4500
+ pullRequestState: state
4501
+ } : layer;
4502
+ }),
4503
+ ...trunk && { base: trunk.ref.replace(/^origin\//, "") }
4504
+ });
4505
+ });
4506
+ app.get("/:id/project", (context) => {
4507
+ const id = context.req.param("id");
4508
+ if (isShared(id)) return context.json({
4509
+ project: null,
4510
+ boards: []
4511
+ });
4512
+ const project = store.summary(id)?.project ?? null;
4513
+ return context.json({
4514
+ project,
4515
+ boards: project ? store.list().filter((board) => board.project?.id === project.id && !board.trashedAt).map(withPullRequestState) : []
4516
+ });
4517
+ });
4518
+ app.get("/:id/links", (context) => {
4519
+ const id = context.req.param("id");
4520
+ if (isShared(id)) return context.json({
4521
+ outgoing: boardLinks(id, readReview(id).document).map(({ from, to }) => ({
4522
+ from,
4523
+ to,
4524
+ missing: true,
4525
+ elsewhere: true
4526
+ })),
4527
+ backlinks: []
4528
+ });
4529
+ const { version } = readQuerySchemas.get.pick({ version: true }).parse({ version: context.req.query("version") });
4530
+ const { outgoing, backlinks } = store.links(id, version);
4531
+ return context.json({
4532
+ outgoing: outgoing.map(withPullRequestState),
4533
+ backlinks: backlinks.map(withPullRequestState)
4534
+ });
4535
+ });
4536
+ app.get("/:id/links/peek", (context) => {
4537
+ const id = context.req.param("id");
4538
+ if (isShared(id)) return context.json({});
4539
+ const { version } = readQuerySchemas.get.pick({ version: true }).parse({ version: context.req.query("version") });
4540
+ const board = context.req.query("board");
4541
+ const block = context.req.query("block");
4542
+ if (!board) throw new ReviewInputError("Name the linked board.", 400);
4543
+ return context.json(store.linkPeek(id, {
4544
+ board,
4545
+ ...block && { block }
4546
+ }, version));
4287
4547
  });
4288
4548
  app.get("/:id/history", (context) => {
4289
4549
  const id = context.req.param("id");
@@ -4292,14 +4552,39 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4292
4552
  return context.json([{
4293
4553
  version: snapshot.version,
4294
4554
  title: snapshot.title,
4295
- createdAt: snapshot.createdAt
4555
+ createdAt: snapshot.createdAt,
4556
+ author: "agent"
4296
4557
  }]);
4297
4558
  });
4559
+ /** A board's relations as session_list reports them: only those it has. */
4560
+ const listedRelations = ({ parentId, children, dependsOn }) => {
4561
+ const listed = {};
4562
+ if (parentId) listed.parentId = parentId;
4563
+ if (children.length) listed.children = children;
4564
+ if (dependsOn.length) listed.dependsOn = dependsOn;
4565
+ return listed;
4566
+ };
4567
+ /** A board's relations, each board named by its title. */
4568
+ const relatedBoards = (id) => {
4569
+ const { parentId, children, dependsOn } = store.relations(id);
4570
+ const named = (board) => ({
4571
+ id: board,
4572
+ title: store.read(board).title
4573
+ });
4574
+ return {
4575
+ ...parentId && { parent: named(parentId) },
4576
+ children: children.map(named),
4577
+ dependsOn: dependsOn.map(named)
4578
+ };
4579
+ };
4298
4580
  app.get("/:id/inspect", (context) => {
4299
4581
  const query = inspectQuerySchema.parse(context.req.query());
4300
4582
  const id = context.req.param("id");
4301
4583
  const snapshot = readReview(id, query.version);
4302
- return context.json(query.format === "text" ? documentText(snapshot, query.targetId, Boolean(query.full)) : query.targetId !== void 0 ? inspectSnapshot(snapshot, query.targetId) : query.full ? snapshot : inspectSnapshot(snapshot));
4584
+ return context.json(query.format === "text" ? documentText(snapshot, query.targetId, Boolean(query.full), isShared(id) ? void 0 : relatedBoards(id)) : query.targetId !== void 0 ? inspectSnapshot(snapshot, query.targetId) : {
4585
+ ...query.full ? snapshot : inspectSnapshot(snapshot),
4586
+ ...!isShared(id) && listedRelations(store.relations(id))
4587
+ });
4303
4588
  });
4304
4589
  app.get("/:id", async (context) => {
4305
4590
  const query = readQuerySchemas.get.parse(context.req.query());
@@ -4326,17 +4611,13 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4326
4611
  };
4327
4612
  app.post("/commands", async (context) => {
4328
4613
  const { command: body, open: requestedOpen } = takeCreateOpen(await readBoundedRequestJson(context.req.raw));
4329
- const request = await locateRepositories(body, (path) => {
4614
+ const register = async (path) => data ? data.register(path) : store.registerRepository(path);
4615
+ const request = withBoardRepository(await locateRepositories(await pullRequestTargets(body, register, (url, repositoryId) => {
4330
4616
  if (!data) throw new ReviewInputError("Repositories are unavailable.");
4331
- return data.register(path);
4332
- });
4617
+ return data.resolvePullRequest(url, { id: repositoryId });
4618
+ }, (id) => store.read(id)), register), (id) => store.has(id) ? store.read(id) : void 0);
4333
4619
  const input = commandSchema.parse(request);
4334
4620
  const command = sharedCommandSchema.safeParse(input);
4335
- if (command.success) {
4336
- refuseDisabledScratchpad(command.data.operation.reviewId);
4337
- await ensureScratchpad(command.data.operation.reviewId);
4338
- }
4339
- if (input.operation.type === "create" && input.operation.kind === "scratchpad") refuseDisabledScratchpad(SCRATCHPAD_ID);
4340
4621
  if (command.success && command.data.operation.reviewId?.startsWith("shared-")) {
4341
4622
  const parsed = commandSchema.parse(input);
4342
4623
  if (shared && parsed.operation.type === "delete") {
@@ -4361,7 +4642,12 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4361
4642
  }
4362
4643
  throw new ReviewInputError("Shared reviews are read-only.", 409);
4363
4644
  }
4364
- const result = await store.execute(input);
4645
+ const threadId = remoteCaller(context) ? void 0 : context.req.header("x-review-thread")?.trim() || void 0;
4646
+ const author = !remoteCaller(context) && context.req.header("x-review-person") !== void 0 ? { kind: "person" } : {
4647
+ kind: "agent",
4648
+ ...threadId && { threadId }
4649
+ };
4650
+ const result = await store.execute(input, void 0, author);
4365
4651
  if (result.deleted || result.lifecycle && store.read(result.reviewId).trashedAt) ask?.threads.closeDeletedReviews((id) => id === result.reviewId);
4366
4652
  if (input.operation.type === "edit" && data && !result.deleted) {
4367
4653
  const { quotes, unquoted } = await anchorQuotes(result.version > 0 ? store.read(result.reviewId, result.version - 1) : void 0, store.read(result.reviewId, result.version), async (pins, side, file) => (await data.file(pins, side, file)).text);
@@ -4382,7 +4668,6 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4382
4668
  if (input.operation.type !== "create") return context.json(result);
4383
4669
  if (result.created !== false) hooks.onReviewCreated?.({
4384
4670
  reviewId: result.reviewId,
4385
- kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
4386
4671
  blocks: store.read(result.reviewId).document.length,
4387
4672
  ...reviewRequestOrigin(context.req.raw.headers)
4388
4673
  });
@@ -4446,6 +4731,95 @@ async function locateRepositories(body, register) {
4446
4731
  }
4447
4732
  };
4448
4733
  }
4734
+ /** Turns an agent's pullRequest target into what the store takes: a create
4735
+ * from pullRequestUrl, or for set_target the PR's resolved commits. */
4736
+ async function pullRequestTargets(body, register, resolve, board) {
4737
+ const target = z.strictObject({
4738
+ kind: z.literal("pullRequest"),
4739
+ repositoryPath: z.string().min(1),
4740
+ pullRequest: z.union([z.string().min(1), z.number().int().positive()])
4741
+ });
4742
+ const located = z.looseObject({ operation: z.discriminatedUnion("type", [z.looseObject({
4743
+ type: z.literal("create"),
4744
+ target
4745
+ }), z.strictObject({
4746
+ type: z.literal("set_target"),
4747
+ reviewId: z.string().min(1),
4748
+ target
4749
+ })]) }).safeParse(body);
4750
+ if (!located.success) return body;
4751
+ const { target: pullRequest, ...operation } = located.data.operation;
4752
+ if (operation.type === "set_target") board(operation.reviewId);
4753
+ const { id: repositoryId } = await register(pullRequest.repositoryPath);
4754
+ const url = pullRequestAt(pullRequest.repositoryPath, pullRequest.pullRequest);
4755
+ if (operation.type === "create") return {
4756
+ ...located.data,
4757
+ operation: {
4758
+ ...operation,
4759
+ pullRequestUrl: url,
4760
+ repositoryId
4761
+ }
4762
+ };
4763
+ return {
4764
+ ...located.data,
4765
+ operation: {
4766
+ ...operation,
4767
+ target: (await resolve(url, repositoryId)).target,
4768
+ pullRequestUrl: url
4769
+ }
4770
+ };
4771
+ }
4772
+ /** A PR's URL: as given, or its number on the checkout's GitHub repository. */
4773
+ function pullRequestAt(repositoryPath, pullRequest) {
4774
+ if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest)) return pullRequest;
4775
+ const slug = resolveRepoContextSync(repositoryPath)?.githubSlug;
4776
+ if (!slug) throw new ReviewInputError("This checkout has no GitHub remote to find the PR number in; pass the PR's URL.");
4777
+ return `https://github.com/${slug}/pull/${pullRequest}`;
4778
+ }
4779
+ /** A pins object as an edit spells it: commits, with no other fields. A
4780
+ * diagram may key its own data `pins`, and that is left alone. */
4781
+ /** Pins as an agent writes them: the board's repository is the host's to
4782
+ * add. */
4783
+ const agentPinsSchema = z.strictObject({
4784
+ head: z.string().min(1),
4785
+ base: z.string().min(1).optional()
4786
+ });
4787
+ /** An object an agent meant as pins: one naming a head, base or repository.
4788
+ * Content may have a field called `pins` that is nothing of the kind. */
4789
+ const meantAsPins = (value) => isJsonObject(value) && [
4790
+ "head",
4791
+ "base",
4792
+ "repositoryId"
4793
+ ].some((key) => key in value);
4794
+ /** Sets every pins object in an edit to the board's own repository. */
4795
+ function withBoardRepository(body, board) {
4796
+ const located = z.looseObject({ operation: z.looseObject({
4797
+ type: z.enum(["edit", "lens_edit"]),
4798
+ reviewId: z.string(),
4799
+ edit: z.json()
4800
+ }) }).safeParse(body);
4801
+ if (!located.success) return body;
4802
+ const snapshot = board(located.data.operation.reviewId);
4803
+ const repositoryId = snapshot ? boardRepositoryId(snapshot) : void 0;
4804
+ const edit = JSON.parse(JSON.stringify(located.data.operation.edit), (key, value) => {
4805
+ if (key !== "pins" || !meantAsPins(value)) return value;
4806
+ const { repositoryId: _ignored, ...written } = value;
4807
+ const pins = agentPinsSchema.safeParse(written);
4808
+ if (!pins.success) throw new ReviewInputError(`Write pins as {head, base?}, each a commit: ${pins.error.issues.map((issue) => `pins.${issue.path.join(".")}: ${issue.message}`).join("; ")}.`);
4809
+ if (!repositoryId) throw new ReviewInputError("This whiteboard has no repository for its pins to name commits of.");
4810
+ return {
4811
+ ...pins.data,
4812
+ repositoryId
4813
+ };
4814
+ });
4815
+ return {
4816
+ ...located.data,
4817
+ operation: {
4818
+ ...located.data.operation,
4819
+ edit
4820
+ }
4821
+ };
4822
+ }
4449
4823
  function watch(lines, subscribe) {
4450
4824
  let stop = () => {};
4451
4825
  const encoder = new TextEncoder();
@@ -4614,7 +4988,7 @@ function createWhiteboardCore(input) {
4614
4988
  if (result.deleted || result.lifecycle) closeDeletedThreads();
4615
4989
  });
4616
4990
  const stopShared = askThreads && shared?.subscribe(closeDeletedThreads);
4617
- const api = createReviewApi(store, data, callbacks.open, shared, callbacks.capabilities, input.scratchpad, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
4991
+ const api = createReviewApi(store, data, callbacks.open, shared, callbacks.capabilities, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
4618
4992
  threads: askThreads,
4619
4993
  agents: async () => detectAskAgents(await ask?.env)
4620
4994
  });
@@ -4899,7 +5273,6 @@ async function serve(input) {
4899
5273
  token: discovery.token,
4900
5274
  instanceId: discovery.instanceId,
4901
5275
  softwareMapEnabled: input.softwareMapEnabled,
4902
- scratchpad: () => false,
4903
5276
  status: () => ({
4904
5277
  key: "headless",
4905
5278
  home: input.stateDir