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

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 (120) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CisiYUZT.js} +13 -8
  2. package/dist/agent-cli-BglNRDZN.js +2 -0
  3. package/dist/{agent-cli-CThN7Q59.js → agent-cli-TJ7_5_B3.js} +15 -10
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-H53ajvrm.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BCquUsB3.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-BSYXD9au.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-Xom8cA_u.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BOnV4cCh.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-cRY5NOno.js} +70 -34
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-UH1bEG7Z.js} +260 -49
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-D_x3wumC.js} +6 -4
  14. package/dist/{headless-host-CVBOQyRS.js → headless-host-BEe-aFtB.js} +1 -1
  15. package/dist/{headless-host-Cog6SpS9.js → headless-host-CEb5d-A0.js} +563 -175
  16. package/dist/lens-selection-BxPk53c8.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-mbCb1HDH.js} +2448 -347
  18. package/dist/{mcp-D1hziANa.js → mcp-cBtzXsmN.js} +9 -12
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BkeQsdaw.js} +1 -1
  20. package/dist/profile-Dbyidrcr.js +2 -0
  21. package/dist/{profile-DokFw782.js → profile-duqp5uPa.js} +20 -8
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-BeKuMm_x.js} +4 -4
  23. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-CX2fAec1.js} +1 -1
  24. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CYWNTVSf.js} +3 -3
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-DLWluAeR.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-Y3S0YmJs.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-icXPv6jh.js} +55 -18
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-DZvG5ZAh.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-BwH08ihj.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-AZ0imsmJ.js} +6 -6
  31. package/dist/runtime-CF4JXdb8.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-DtJFWC0G.js → s3-CsG2EWED.js} +1 -1
  35. package/dist/{s3-config-BTO12tCC.js → s3-config-CjcLyFV5.js} +4 -2
  36. package/dist/{s3-config-CSy3fx8H.js → s3-config-zz4GWh2T.js} +1 -1
  37. package/dist/server/desktop-host.js +28 -104
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-DeVTdcqK.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -74
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-DYZLCUP_.js} +98 -18
  42. package/dist/{src-BMPuxWv4.js → src-DtfSndqS.js} +269 -227
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B29lve9-.js} +318 -271
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-BPpvKFwQ.js} +12 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-CQhyLht3.js} +10 -3
  46. package/instructions/authoring.md +20 -5
  47. package/package.json +4 -4
  48. package/src/agent-selection.ts +6 -0
  49. package/src/ask/agents.ts +13 -1
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +61 -31
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-install.ts +13 -2
  55. package/src/cli-runner.ts +4 -1
  56. package/src/cli.ts +4 -1
  57. package/src/diff-selection-migration.ts +48 -34
  58. package/src/heading-anchors.ts +42 -0
  59. package/src/lens-selection.ts +38 -2
  60. package/src/markdown.ts +42 -0
  61. package/src/posthog-capture-client.ts +1 -1
  62. package/src/review-api/README.md +52 -13
  63. package/src/review-api/activity.ts +1 -4
  64. package/src/review-api/agent-cli.ts +9 -1
  65. package/src/review-api/anchor-quotes.ts +14 -7
  66. package/src/review-api/ask-history.ts +118 -51
  67. package/src/review-api/authoring-tools.ts +45 -30
  68. package/src/review-api/azure-devops-provider.ts +2 -20
  69. package/src/review-api/azure-remote.ts +21 -0
  70. package/src/review-api/blocks/definition.ts +1 -1
  71. package/src/review-api/blocks/markdown.ts +2 -2
  72. package/src/review-api/board-order.ts +144 -0
  73. package/src/review-api/checkout-groups.ts +586 -0
  74. package/src/review-api/document-headings.ts +58 -27
  75. package/src/review-api/document-text.ts +31 -2
  76. package/src/review-api/document.ts +362 -67
  77. package/src/review-api/foreign-references.ts +328 -0
  78. package/src/review-api/github-provider.ts +51 -13
  79. package/src/review-api/http.ts +734 -135
  80. package/src/review-api/instructions.ts +0 -19
  81. package/src/review-api/link-preview.ts +113 -0
  82. package/src/review-api/local-data.ts +310 -46
  83. package/src/review-api/mcp.ts +7 -13
  84. package/src/review-api/onboarding.ts +1 -1
  85. package/src/review-api/profile.ts +37 -6
  86. package/src/review-api/public-tools.ts +24 -2
  87. package/src/review-api/pull-request.ts +82 -49
  88. package/src/review-api/request-origin.ts +10 -0
  89. package/src/review-api/review-progress.ts +61 -27
  90. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  91. package/src/review-api/store-migrations.ts +355 -0
  92. package/src/review-api/store-schema.ts +38 -29
  93. package/src/review-api/store.ts +2176 -495
  94. package/src/review-api/worktree-events.ts +3 -5
  95. package/src/review-api/worktree-source.ts +3 -7
  96. package/src/review-api/worktree-structural.integration.ts +72 -0
  97. package/src/review-stack.ts +15 -23
  98. package/src/runtime.ts +4 -30
  99. package/src/server/desktop-host.ts +6 -1
  100. package/src/server/desktop-server.ts +1 -36
  101. package/src/server/headless-host.ts +0 -2
  102. package/src/server/hono-http.ts +2 -1
  103. package/src/server/review-gateway-list.ts +3 -5
  104. package/src/server/review-gateway-memory.ts +1 -1
  105. package/src/server/review-lifecycle-telemetry.ts +2 -2
  106. package/src/server/review-server-core.ts +1 -5
  107. package/src/server/structural-comparisons.ts +47 -0
  108. package/src/sharing/export.ts +36 -7
  109. package/src/sharing/host.ts +34 -14
  110. package/src/sharing/import.ts +156 -24
  111. package/src/sharing/repository.ts +143 -24
  112. package/src/startup-trace.ts +3 -3
  113. package/src/stored-document-migration.ts +110 -19
  114. package/src/ui-telemetry-events.ts +44 -18
  115. package/dist/agent-cli-C6SPSII0.js +0 -2
  116. package/dist/profile-CYPmvBoA.js +0 -2
  117. package/dist/runtime-CKiWhGrf.js +0 -52
  118. package/instructions/scratchpad.md +0 -23
  119. package/src/review-preferences.ts +0 -88
  120. package/src/server/legacy-tutorial-cleanup.ts +0 -38
@@ -1,21 +1,22 @@
1
- import { d as git, r as defaultBranch } from "./src-BMPuxWv4.js";
1
+ import { b as resolveRepoContext, d as git, r as defaultBranch, w as runVcs } from "./src-DtfSndqS.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-D_9tvdsp.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-j2BRAaNv.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-UH1bEG7Z.js";
4
+ import { f as textualRows, h as diffrMissingError, i as inspectSnapshot, m as diffrExecutable, p as invalidateStructuralComparisons, r as commandSchema } from "./local-data-mbCb1HDH.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, y as REVIEW_HOST_HEADER } from "./src-DYZLCUP_.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-BwH08ihj.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-BxPk53c8.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-B29lve9-.js";
8
9
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
9
- import { A as processStartIdentity, L as writePrivateJsonAtomic, N as devReviewHome, h as TraceConfigurationError, i as isS3MockMode, j as withFileLock } from "./s3-config-BTO12tCC.js";
10
+ import { A as processStartIdentity, L as writePrivateJsonAtomic, N as devReviewHome, h as TraceConfigurationError, i as isS3MockMode, j as withFileLock } from "./s3-config-CjcLyFV5.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-C--__jEv.js";
12
- import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-Bey8IqXH.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-DokFw782.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, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-BO9bK37N.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-BNwuMnz6.js";
18
- import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-CkvxA-6O.js";
12
+ import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPackageRoot } from "./package-paths-BkeQsdaw.js";
13
+ import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-DeVTdcqK.js";
14
+ import { c as askPicksSchema, d as selectionMarkdown, l as askQuestionSchema, n as askAgentIds, r as askChoiceKinds, s as askPermissionsSchema, t as applyAskChange, u as AgentSelectionSchema } from "./thread-state-BPpvKFwQ.js";
15
+ import { t as openReviewProfile } from "./profile-duqp5uPa.js";
16
+ import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-icXPv6jh.js";
17
+ import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-D_x3wumC.js";
18
+ import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-BCquUsB3.js";
19
+ import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-Xom8cA_u.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";
@@ -196,6 +197,12 @@ const STEER_METHOD = "_session/steering";
196
197
  /** How an agent says, in its `initialize` response, that it takes steering. */
197
198
  const steeringSchema = z.object({ steering: z.object({ supported: z.literal(true) }) });
198
199
  const steerOutcomeSchema = z.object({ outcome: z.string() });
200
+ /** A review Codex couldn't run; one it ran has a risk level. */
201
+ const failedReviewSchema = z.object({ review: z.object({
202
+ status: z.literal("denied"),
203
+ riskLevel: z.null(),
204
+ rationale: z.string()
205
+ }) });
199
206
  /** MCP servers the reviewer's own agent may bring whose tools only read,
200
207
  * so their calls run without asking, like the agent reading files. */
201
208
  const READ_ONLY_MCP_SERVERS = /* @__PURE__ */ new Set(["fff"]);
@@ -213,6 +220,7 @@ var AskThread = class {
213
220
  /** The number of changes so far; a snapshot carries the one it includes. */
214
221
  seq = 0;
215
222
  listeners = /* @__PURE__ */ new Set();
223
+ statusListeners = /* @__PURE__ */ new Set();
216
224
  closers = /* @__PURE__ */ new Set();
217
225
  decisions = /* @__PURE__ */ new Map();
218
226
  process;
@@ -248,6 +256,8 @@ var AskThread = class {
248
256
  needsContext;
249
257
  /** The reviewer changed what the agent may do since it was last told. */
250
258
  permitted = false;
259
+ /** Why the agent's automatic approval review failed this turn. */
260
+ reviewFailure;
251
261
  /** The agent could not reopen the session, for a reason signing in would
252
262
  * not fix, so trying again starts a new one. */
253
263
  unloadable = false;
@@ -261,9 +271,6 @@ var AskThread = class {
261
271
  steerable = false;
262
272
  /** Follow-ups waiting for the turn under way to end, oldest first. */
263
273
  queue = [];
264
- /** The review the thread answers in, and where it saves; adopting moves
265
- * both. */
266
- home;
267
274
  constructor(launch, start, mcpServers = () => [], limits = askThreadLimits) {
268
275
  this.launch = launch;
269
276
  this.start = start;
@@ -271,15 +278,6 @@ var AskThread = class {
271
278
  this.id = start.id ?? randomUUID();
272
279
  this.mcpServers = mcpServers(this.id);
273
280
  this.picks = { ...start.picks };
274
- this.home = {
275
- reviewId: start.reviewId,
276
- onSession: start.onSession,
277
- onTurn: start.onTurn,
278
- onSave: start.onSave,
279
- onPermissions: start.onPermissions,
280
- onOffer: start.onOffer,
281
- onTitle: start.onTitle
282
- };
283
281
  this.state = {
284
282
  id: this.id,
285
283
  agent: start.agent,
@@ -297,17 +295,7 @@ var AskThread = class {
297
295
  this.needsContext = !("resume" in start);
298
296
  }
299
297
  get reviewId() {
300
- return this.home.reviewId;
301
- }
302
- /** Moves the thread to another review, as when its agent creates the
303
- * review it was asked for, and saves it there as it stands. */
304
- adopt(reviewId, hooks) {
305
- this.home = {
306
- reviewId,
307
- ...hooks
308
- };
309
- if (this.sessionId) hooks.onSession?.(this.sessionId);
310
- hooks.onSave?.(this.state.entries);
298
+ return this.start.reviewId;
311
299
  }
312
300
  read() {
313
301
  return this.state;
@@ -318,11 +306,19 @@ var AskThread = class {
318
306
  snapshot: this.state
319
307
  };
320
308
  }
309
+ get status() {
310
+ return this.state.status;
311
+ }
321
312
  /** Every change after the current `seq`, in order. */
322
313
  subscribe(listener) {
323
314
  this.listeners.add(listener);
324
315
  return () => this.listeners.delete(listener);
325
316
  }
317
+ /** Calls back when the status changes, without following the thread. */
318
+ onStatusChange(listener) {
319
+ this.statusListeners.add(listener);
320
+ return () => this.statusListeners.delete(listener);
321
+ }
326
322
  /** Whether anything follows the thread, as an open panel does. */
327
323
  watched() {
328
324
  return this.listeners.size > 0;
@@ -487,7 +483,7 @@ var AskThread = class {
487
483
  if (this.closed) return;
488
484
  this.closed = true;
489
485
  try {
490
- if (save && this.sessionId && this.state.status !== "starting" && this.state.status !== "failed") this.home.onSave?.(settled(this.state.entries));
486
+ if (save && this.sessionId && this.state.status !== "starting" && this.state.status !== "failed") this.start.onSave?.(settled(this.state.entries));
491
487
  } finally {
492
488
  for (const resolve of this.decisions.values()) resolve({ outcome: { outcome: "cancelled" } });
493
489
  if (this.connection && this.sessionId) this.connection.agent.notify(methods.agent.session.cancel, { sessionId: this.sessionId }).catch(() => {});
@@ -579,7 +575,8 @@ var AskThread = class {
579
575
  const generation = ++this.generation;
580
576
  const launched = await this.launch(this.start.agent, this.start.cwd, {
581
577
  permissions: this.state.permissions,
582
- mcpServers: this.mcpServers
578
+ mcpServers: this.mcpServers,
579
+ threadId: this.id
583
580
  });
584
581
  if (this.closed || this.halted || generation !== this.generation) {
585
582
  launched.stop();
@@ -704,7 +701,7 @@ var AskThread = class {
704
701
  const offer = { choices };
705
702
  if (commands) offer.commands = commands;
706
703
  if (accepts) offer.accepts = accepts;
707
- this.home.onOffer?.(offer);
704
+ this.start.onOffer?.(offer);
708
705
  }
709
706
  /** Changes what the agent may do from the next answer: it starts again
710
707
  * in that mode and reopens the conversation. */
@@ -717,7 +714,7 @@ var AskThread = class {
717
714
  permissions
718
715
  });
719
716
  this.permitted = true;
720
- this.home.onPermissions?.(permissions);
717
+ this.start.onPermissions?.(permissions);
721
718
  this.disconnect();
722
719
  this.settings.clear();
723
720
  this.emit({
@@ -759,7 +756,7 @@ var AskThread = class {
759
756
  });
760
757
  if (this.closed) throw new Error("This conversation has ended.");
761
758
  this.sessionId = response.sessionId;
762
- this.home.onSession?.(response.sessionId);
759
+ this.start.onSession?.(response.sessionId);
763
760
  this.greeting = piGreetingSchema.safeParse(response._meta).data?.piAcp.startupInfo;
764
761
  return {
765
762
  sessionId: response.sessionId,
@@ -838,13 +835,23 @@ var AskThread = class {
838
835
  severity: "info",
839
836
  title: `${this.state.agentName} finished without replying.`
840
837
  });
838
+ const reviewFailed = this.reviewFailure !== void 0 && this.state.permissions === "auto";
839
+ if (reviewFailed) this.push({
840
+ kind: "notice",
841
+ id: randomUUID(),
842
+ severity: "warning",
843
+ title: `${this.state.agentName}'s automatic approval review failed, so this conversation is now read-only. Ask again to continue.`,
844
+ description: this.reviewFailure
845
+ });
846
+ this.reviewFailure = void 0;
841
847
  this.emit({
842
848
  type: "set",
843
849
  status: "idle",
844
850
  error: stopReason === "refusal" ? `${this.state.agentName} declined to answer.` : stopReason === "max_tokens" || stopReason === "max_turn_requests" ? `${this.state.agentName} stopped before finishing.` : null
845
851
  });
846
- this.home.onTurn?.();
847
- this.home.onSave?.(this.state.entries);
852
+ this.start.onTurn?.();
853
+ this.start.onSave?.(this.state.entries);
854
+ if (reviewFailed) await this.permit("read-only");
848
855
  this.askQueued();
849
856
  }
850
857
  async requestPermission(request, signal) {
@@ -957,6 +964,7 @@ var AskThread = class {
957
964
  }
958
965
  case "tool_call":
959
966
  this.noteMcpServer(update);
967
+ this.noteReview(update);
960
968
  this.push({
961
969
  kind: "tool",
962
970
  id: update.toolCallId,
@@ -969,6 +977,7 @@ var AskThread = class {
969
977
  return;
970
978
  case "tool_call_update":
971
979
  this.noteMcpServer(update);
980
+ this.noteReview(update);
972
981
  if (!this.state.entries.some((entry) => entry.kind === "tool" && entry.id === update.toolCallId)) {
973
982
  this.push({
974
983
  kind: "tool",
@@ -1034,7 +1043,7 @@ var AskThread = class {
1034
1043
  type: "set",
1035
1044
  title
1036
1045
  });
1037
- this.home.onTitle?.(title);
1046
+ this.start.onTitle?.(title);
1038
1047
  }
1039
1048
  return true;
1040
1049
  }
@@ -1045,6 +1054,10 @@ var AskThread = class {
1045
1054
  const server = mcpServerOf(update);
1046
1055
  if (server) this.mcpCalls.set(update.toolCallId, server);
1047
1056
  }
1057
+ noteReview(update) {
1058
+ const failed = failedReviewSchema.safeParse(update.rawOutput ?? update.rawInput).data;
1059
+ if (failed && !this.replaying) this.reviewFailure = failed.review.rationale;
1060
+ }
1048
1061
  /**
1049
1062
  * In its read-only mode Claude answers by writing a plan and asking to
1050
1063
  * leave the mode with it. That request is refused, so the plan is shown
@@ -1177,6 +1190,7 @@ var AskThread = class {
1177
1190
  change
1178
1191
  };
1179
1192
  for (const listener of this.listeners) listener(update);
1193
+ if (change.type === "set" && change.status) for (const listener of this.statusListeners) listener();
1180
1194
  }
1181
1195
  };
1182
1196
  //#endregion
@@ -1261,13 +1275,16 @@ function cliAskTools(cliPath, selector) {
1261
1275
  }] : []
1262
1276
  ];
1263
1277
  return {
1264
- mcpServers: () => {
1278
+ mcpServers: (threadId) => {
1265
1279
  const cli = cliPath();
1266
1280
  return cli ? [{
1267
1281
  name: "whiteboard",
1268
1282
  command: process.execPath,
1269
1283
  args: [cli, "mcp"],
1270
- env: env()
1284
+ env: [...env(), {
1285
+ name: ASK_THREAD_ENV,
1286
+ value: threadId
1287
+ }]
1271
1288
  }] : [];
1272
1289
  },
1273
1290
  cli: () => {
@@ -1295,6 +1312,7 @@ var AskThreads = class {
1295
1312
  * model. */
1296
1313
  offers = /* @__PURE__ */ new Map();
1297
1314
  mcpServers;
1315
+ statusListeners = /* @__PURE__ */ new Set();
1298
1316
  constructor(launch, tools = {}, limits = askThreadLimits, unwatchedIdleMs = UNWATCHED_IDLE_MS) {
1299
1317
  this.launch = launch;
1300
1318
  this.tools = tools;
@@ -1315,6 +1333,8 @@ var AskThreads = class {
1315
1333
  open(start) {
1316
1334
  const thread = new AskThread(this.launch, start, this.mcpServers, this.limits);
1317
1335
  this.threads.set(thread.id, thread);
1336
+ thread.onStatusChange(() => this.statusChanged());
1337
+ this.statusChanged();
1318
1338
  thread.open();
1319
1339
  this.sweeper ??= setInterval(() => this.sweep(), SWEEP_MS);
1320
1340
  this.sweeper.unref?.();
@@ -1345,6 +1365,16 @@ var AskThreads = class {
1345
1365
  if (now - since >= this.unwatchedIdleMs) this.close(id);
1346
1366
  }
1347
1367
  }
1368
+ /** Calls back when a thread opens, closes or changes its status. */
1369
+ subscribeStatus(listener) {
1370
+ this.statusListeners.add(listener);
1371
+ return () => {
1372
+ this.statusListeners.delete(listener);
1373
+ };
1374
+ }
1375
+ statusChanged() {
1376
+ for (const listener of this.statusListeners) listener();
1377
+ }
1348
1378
  get(id) {
1349
1379
  return this.threads.get(id);
1350
1380
  }
@@ -1361,9 +1391,9 @@ var AskThreads = class {
1361
1391
  }
1362
1392
  close(id, save = true) {
1363
1393
  const thread = this.threads.get(id);
1364
- this.threads.delete(id);
1365
1394
  this.unwatchedSince.delete(id);
1366
1395
  thread?.close(save);
1396
+ if (this.threads.delete(id)) this.statusChanged();
1367
1397
  }
1368
1398
  closeDeletedReviews(isDeleted) {
1369
1399
  for (const [id, thread] of this.threads) if (isDeleted(thread.reviewId)) this.close(id, false);
@@ -1376,7 +1406,7 @@ var AskThreads = class {
1376
1406
  };
1377
1407
  //#endregion
1378
1408
  //#region src/server/diffr-config.ts
1379
- const execFileAsync$2 = promisify(execFile);
1409
+ const execFileAsync = promisify(execFile);
1380
1410
  let writes = Promise.resolve();
1381
1411
  let testing = false;
1382
1412
  function serialized(operation) {
@@ -1417,7 +1447,7 @@ function diffrArgv(args) {
1417
1447
  async function diffr(args, rootPath, options = {}) {
1418
1448
  const executable = diffrExecutable();
1419
1449
  try {
1420
- const operation = execFileAsync$2(executable, diffrArgv(args), {
1450
+ const operation = execFileAsync(executable, diffrArgv(args), {
1421
1451
  cwd: rootPath,
1422
1452
  maxBuffer: 16777216,
1423
1453
  signal: options.signal ?? AbortSignal.timeout(3e4),
@@ -1847,7 +1877,7 @@ function applyCorsHeaders(request, response) {
1847
1877
  const varyFields = vary?.split(",").map((field) => field.trim().toLowerCase());
1848
1878
  if (!varyFields?.includes("*") && !varyFields?.includes("origin")) response.headers.set("vary", vary ? `${vary}, Origin` : "Origin");
1849
1879
  }
1850
- response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_APP_SESSION_ID_HEADER}`);
1880
+ response.headers.set("access-control-allow-headers", `content-type, x-review-token, ${REVIEW_PERSON_HEADER}, ${REVIEW_APP_SESSION_ID_HEADER}`);
1851
1881
  response.headers.set("access-control-allow-methods", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
1852
1882
  response.headers.set("access-control-allow-private-network", "true");
1853
1883
  response.headers.set("access-control-expose-headers", REVIEW_HOST_HEADER);
@@ -2276,11 +2306,15 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2276
2306
  return [];
2277
2307
  }
2278
2308
  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]);
2309
+ const [base, head] = await Promise.all(["base", "head"].map((side) => data.file(sourcePins, side, source.file).then((value) => value.text, (error) => {
2310
+ if (error instanceof ReviewInputError && error.status === 404) return null;
2311
+ throw error;
2312
+ })));
2313
+ if (base === null && head === null) throw new SelectionUnavailableError("missing_file", "File is unavailable at these pins.");
2314
+ if (base !== null && head !== null && base !== head) throw new SelectionUnavailableError("no_alignment", "Changed context file has no alignment.");
2315
+ rows = textualRows(source.file, "", base?.split("\n").length ?? 0, head?.split("\n").length ?? 0);
2282
2316
  }
2283
- if (!rows) throw new Error("File has no text alignment.");
2317
+ if (!rows) throw new SelectionUnavailableError("no_alignment", "File has no text alignment.");
2284
2318
  const result = resolveDiffSelection(source, rows, file ?? { path: source.file });
2285
2319
  resolvedSelections[key] = result;
2286
2320
  return result;
@@ -2288,8 +2322,8 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2288
2322
  const resolveAvailable = async (source) => {
2289
2323
  try {
2290
2324
  return await resolve(source);
2291
- } catch {
2292
- unavailableSelections[selectionKey(source)] = "Source links need updating at these pins";
2325
+ } catch (error) {
2326
+ unavailableSelections[selectionKey(source)] = error instanceof SelectionUnavailableError ? error.reason : "other";
2293
2327
  return [];
2294
2328
  }
2295
2329
  };
@@ -2315,7 +2349,10 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2315
2349
  };
2316
2350
  }
2317
2351
  }));
2318
- await Promise.all(selectionReferences(snapshot.document, { tolerant: true }).map(async ({ source }) => {
2352
+ await Promise.all(selectionReferences(snapshot.document, {
2353
+ tolerant: true,
2354
+ repositoryId: boardRepositoryId(snapshot)
2355
+ }).map(async ({ source }) => {
2319
2356
  try {
2320
2357
  await resolveAvailable(source);
2321
2358
  } catch {}
@@ -2331,7 +2368,7 @@ async function reviewProgress(store, data, snapshot, signal = new AbortControlle
2331
2368
  unavailable: counting || uncategorized.length ? void 0 : "All changed lines are covered by lenses"
2332
2369
  });
2333
2370
  const progress = {
2334
- complete: !counting,
2371
+ complete: !counting && !pendingSources.size,
2335
2372
  files,
2336
2373
  referenceFiles,
2337
2374
  lenses,
@@ -2388,12 +2425,11 @@ function lensReport(lenses, progress) {
2388
2425
  }
2389
2426
  //#endregion
2390
2427
  //#region src/ask/checkout-files.ts
2391
- const execFileAsync$1 = promisify(execFile);
2392
2428
  /** The checkout's files relative to its root: tracked ones, and new ones git
2393
2429
  * does not ignore. None where git cannot list them. */
2394
2430
  async function checkoutFiles(root) {
2395
2431
  try {
2396
- const { stdout } = await execFileAsync$1("git", [
2432
+ const { stdout } = await runVcs("git", [
2397
2433
  "-C",
2398
2434
  root,
2399
2435
  "ls-files",
@@ -2564,7 +2600,6 @@ function watchAskThreads(threads, backlog = 256) {
2564
2600
  }
2565
2601
  //#endregion
2566
2602
  //#region src/review-stack.ts
2567
- const execFileAsync = promisify(execFile);
2568
2603
  const GitHubStacksSchema = z.array(z.object({ pull_requests: z.array(z.object({
2569
2604
  number: z.number().int().positive(),
2570
2605
  head: z.object({ ref: z.string().min(1) })
@@ -2612,12 +2647,12 @@ async function gitHubStack(subject, reviews, runGitHubApi) {
2612
2647
  }
2613
2648
  /** The chain of reviews through this one, linked by the base each names. */
2614
2649
  async function localStack(subject, reviews, repositoryPath) {
2615
- const group = reviews.find((review) => review.reviewId === subject.reviewId)?.repositoryGroup?.key;
2650
+ const group = reviews.find((review) => review.reviewId === subject.reviewId)?.project?.id;
2616
2651
  const current = subject.origin?.branch;
2617
2652
  if (!group || !current) return [];
2618
2653
  const main = subject.origin?.baseBranch ? await defaultBranch(repositoryPath).catch(() => null) : null;
2619
2654
  const name = (ref) => ref.replace(/^origin\//, "");
2620
- const inGroup = reviews.filter((review) => review.repositoryGroup?.key === group);
2655
+ const inGroup = reviews.filter((review) => review.project?.id === group);
2621
2656
  const latest = (field, branch) => {
2622
2657
  const matches = inGroup.filter((review) => review.origin?.[field] === branch);
2623
2658
  return matches.findLast((review) => !review.trashedAt) ?? matches.at(-1);
@@ -2644,8 +2679,8 @@ async function forkPoint(repositoryPath, main, branch) {
2644
2679
  const ref = `refs/heads/${branch}`;
2645
2680
  const fork = await git(repositoryPath, [
2646
2681
  "merge-base",
2647
- main,
2648
- ref
2682
+ { rev: main },
2683
+ { rev: ref }
2649
2684
  ], { allowFailure: true });
2650
2685
  if (!fork.ok) return null;
2651
2686
  const base = fork.stdout.trim();
@@ -2655,22 +2690,17 @@ async function forkPoint(repositoryPath, main, branch) {
2655
2690
  "log",
2656
2691
  "--reverse",
2657
2692
  "--format=%s",
2658
- `${base}..${ref}`
2693
+ { rev: `${base}..${ref}` }
2659
2694
  ], { allowFailure: true })).stdout.split("\n")[0] || null
2660
2695
  };
2661
2696
  }
2662
2697
  async function defaultRunGitHubApi(host, endpoint) {
2663
- const { stdout } = await execFileAsync("gh", [
2698
+ const { stdout } = await runVcs("gh", [
2664
2699
  "api",
2665
2700
  "--hostname",
2666
- host,
2667
- endpoint
2701
+ { value: host },
2702
+ { value: endpoint }
2668
2703
  ], {
2669
- env: {
2670
- ...process.env,
2671
- GH_PROMPT_DISABLED: "1"
2672
- },
2673
- encoding: "utf8",
2674
2704
  timeout: 1e4,
2675
2705
  maxBuffer: 2097152
2676
2706
  });
@@ -2708,7 +2738,6 @@ const publishSchema = z.strictObject({
2708
2738
  /** Mounted behind local host authentication. Account credentials never enter the renderer. */
2709
2739
  function mountSharingHost(app, store, data, shared, options = {}) {
2710
2740
  const startLogin = options.login ?? runStoreLogin;
2711
- const openUrl = options.openUrl ?? openUrlInBrowser;
2712
2741
  let login = { pending: false };
2713
2742
  app.get("/sharing/account", async (context) => {
2714
2743
  const account = await readStoreAuth();
@@ -2731,9 +2760,9 @@ function mountSharingHost(app, store, data, shared, options = {}) {
2731
2760
  traces: false,
2732
2761
  stdout: discard,
2733
2762
  stderr: discard,
2734
- openUrl: async (url) => {
2735
- login.url = url;
2736
- await openUrl(url);
2763
+ openUrl: options.openUrl,
2764
+ onDeviceCode: (device) => {
2765
+ Object.assign(login, device);
2737
2766
  }
2738
2767
  }).then((code) => {
2739
2768
  login = { pending: false };
@@ -2802,8 +2831,16 @@ function mountSharingPublisher(app, store, data, options = {}) {
2802
2831
  validateShareSources(snapshot);
2803
2832
  const account = await readSharingAuth();
2804
2833
  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;
2834
+ const repositoryId = snapshot.pins?.repositoryId ?? snapshot.target?.repositoryId;
2835
+ const root = repositoryId ? store.repositoryPath(repositoryId) : void 0;
2836
+ const readRepository = options.readRepository ?? readShareRepository;
2837
+ const repository = !root ? void 0 : snapshot.pins ? await readRepository(root) : await (async () => {
2838
+ try {
2839
+ return await readRepository(root);
2840
+ } catch {
2841
+ return;
2842
+ }
2843
+ })();
2807
2844
  const bundle = await exportShare({
2808
2845
  store,
2809
2846
  data,
@@ -2811,7 +2848,8 @@ function mountSharingPublisher(app, store, data, options = {}) {
2811
2848
  version: snapshot.version,
2812
2849
  repository
2813
2850
  });
2814
- const verification = root && snapshot.pins && repository ? verifyRepository(root, snapshot.pins, repository).then(() => ({ ok: true }), (error) => ({
2851
+ const commits = referenceCommits(snapshot);
2852
+ const verification = root && (snapshot.pins || commits.length) && repository ? verifyRepository(root, snapshot.pins, commits).then(() => ({ ok: true }), (error) => ({
2815
2853
  ok: false,
2816
2854
  error
2817
2855
  })) : Promise.resolve({ ok: true });
@@ -2887,8 +2925,14 @@ const clip = (line) => line.trim() === "" ? "(blank line)" : line.length > 120 ?
2887
2925
  */
2888
2926
  async function anchorQuotes(before, after, read) {
2889
2927
  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)));
2928
+ const known = new Set(before ? selectionReferences(before.document, {
2929
+ tolerant: true,
2930
+ repositoryId: boardRepositoryId(before)
2931
+ }).map(({ id, source }) => key(id, source)) : []);
2932
+ const added = selectionReferences(after.document, {
2933
+ tolerant: true,
2934
+ repositoryId: boardRepositoryId(after)
2935
+ }).filter(({ id, source }) => !known.has(key(id, source)));
2892
2936
  return {
2893
2937
  quotes: (await Promise.all(added.slice(0, QUOTE_LIMIT).map(async ({ source }) => {
2894
2938
  try {
@@ -2913,15 +2957,18 @@ async function anchorQuotes(before, after, read) {
2913
2957
  //#endregion
2914
2958
  //#region src/review-api/document-text.ts
2915
2959
  /** A reading view of saved content, not another document format to maintain. */
2916
- function documentText(snapshot, targetId, full = false) {
2960
+ function documentText(snapshot, targetId, full = false, relations) {
2917
2961
  const target = targetId === void 0 ? void 0 : elements(snapshot.document).find((item) => item.id === targetId);
2918
2962
  if (targetId !== void 0 && !target) throw new ReviewInputError("Target not found in this version.", 404);
2919
2963
  const detailed = full || target !== void 0;
2920
- const lines = [
2921
- `# ${snapshot.title}`,
2922
- `Session ${snapshot.reviewId} · version ${snapshot.version}`,
2923
- ""
2924
- ];
2964
+ const lines = [`# ${boardTitle(snapshot.title)}`, `Session ${snapshot.reviewId} · version ${snapshot.version}`];
2965
+ if (relations && targetId === void 0) {
2966
+ const list = (boards) => boards.map((board) => `${boardTitle(board.title)} (${board.id})`).join(", ");
2967
+ if (relations.parent) lines.push(`Parent: ${list([relations.parent])}`);
2968
+ if (relations.children.length) lines.push(`Children, in order: ${list(relations.children)}`);
2969
+ if (relations.dependsOn.length) lines.push(`Depends on: ${list(relations.dependsOn)}`);
2970
+ }
2971
+ lines.push("");
2925
2972
  const write = (depth, text) => {
2926
2973
  for (const line of text.split("\n")) lines.push(" ".repeat(depth) + line);
2927
2974
  };
@@ -3349,7 +3396,7 @@ function registerOnboardingRoutes(app, store, ask, host, run = runCommand) {
3349
3396
  agent: input.agent,
3350
3397
  ...host.historyHooks({
3351
3398
  id,
3352
- reviewId,
3399
+ anchor: { board: reviewId },
3353
3400
  agent: input.agent,
3354
3401
  version: created.version,
3355
3402
  head,
@@ -3497,12 +3544,16 @@ const askDecisionSchema = z.strictObject({
3497
3544
  permissionId: z.string().min(1),
3498
3545
  optionId: z.string().min(1)
3499
3546
  });
3500
- const SCRATCHPAD_DISABLED = "The scratchpad is off. Turn it on in Whiteboard Desktop Settings.";
3547
+ /** A person's save carries the whole board, which edits one at a time can
3548
+ * grow past the size of a single command. */
3549
+ const PERSON_EDIT_MAX_BYTES = 16777216;
3550
+ /** A request the gateway forwarded from another machine. */
3551
+ const remoteCaller = (context) => context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
3501
3552
  /** Both hosts mount this behind their token authentication. */
3502
3553
  function createReviewApi(store, data, open, shared, capabilities = () => ({
3503
3554
  desktopAvailable: Boolean(open),
3504
3555
  softwareMapEnabled: false
3505
- }), scratchpadEnabled = () => false, traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
3556
+ }), traceEnabled = async () => false, status = () => ({}), hooks = {}, ask) {
3506
3557
  const app = new Hono();
3507
3558
  app.onError((error, context) => {
3508
3559
  if (error instanceof HttpJsonError) return context.json({ error: error.message }, error.statusCode);
@@ -3531,15 +3582,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3531
3582
  await next();
3532
3583
  };
3533
3584
  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
3585
  const sharedGuard = async (context, next) => {
3541
- refuseDisabledScratchpad(context.req.param("id"));
3542
- await ensureScratchpad(context.req.param("id"));
3543
3586
  const id = context.req.param("id");
3544
3587
  if (!id || !isShared(id)) return next();
3545
3588
  const query = readQuerySchemas.get.parse({ version: context.req.query("version") });
@@ -3558,23 +3601,53 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3558
3601
  if (!snapshot || version !== void 0 && version !== snapshot.version) throw new ReviewInputError("Shared review version is unavailable.", 404);
3559
3602
  return snapshot;
3560
3603
  };
3561
- const catalog = () => {
3562
- const local = store.list();
3563
- return [...scratchpadEnabled() ? local : local.filter((summary) => summary.kind !== "scratchpad"), ...shared?.list() ?? []];
3604
+ /** Adds a PR board's state, as this host last read it from GitHub. */
3605
+ const withPullRequestState = (board) => {
3606
+ const state = data?.pullRequestState(board.origin?.pullRequestUrl ?? board.pullRequestUrl);
3607
+ return state ? {
3608
+ ...board,
3609
+ pullRequestState: state
3610
+ } : board;
3564
3611
  };
3565
- app.get("/", async (context) => {
3566
- await ensureScratchpad();
3567
- return context.json(catalog());
3568
- });
3612
+ const catalog = () => [...store.list().map(withPullRequestState), ...shared?.list() ?? []];
3613
+ /** Where a board sits among its project's, and its PR's facts, for the
3614
+ * board's header. */
3615
+ const placeOf = (id) => {
3616
+ if (isShared(id) || !store.has(id)) return { siblings: [] };
3617
+ const { board, parent, siblings, children } = store.place(id);
3618
+ const url = board.origin?.pullRequestUrl;
3619
+ const state = data?.pullRequestState(url);
3620
+ const baseBranch = url && data?.pullRequestBaseBranch(url);
3621
+ const headBranch = url && data?.pullRequestHeadBranch(url);
3622
+ const place = ({ reviewId, title, target }) => ({
3623
+ reviewId,
3624
+ title,
3625
+ icon: boardIconKind(target)
3626
+ });
3627
+ const listed = (child) => {
3628
+ const childState = data?.pullRequestState(child.origin?.pullRequestUrl);
3629
+ return {
3630
+ ...place(child),
3631
+ ...child.origin?.pullRequestNumber && { pullRequestNumber: child.origin.pullRequestNumber },
3632
+ ...childState && { pullRequestState: childState }
3633
+ };
3634
+ };
3635
+ return {
3636
+ ...state && { pullRequestState: state },
3637
+ ...baseBranch && { baseBranch },
3638
+ ...headBranch && { headBranch },
3639
+ board: place(board),
3640
+ ...parent && { parent: place(parent) },
3641
+ siblings: siblings.map(listed),
3642
+ ...children?.length && { children: children.map(listed) }
3643
+ };
3644
+ };
3645
+ app.get("/", (context) => context.json(catalog()));
3569
3646
  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
- });
3647
+ const instructionContext = async () => ({ traceEnabled: await traceEnabled() });
3575
3648
  app.get("/authoring", async (context) => {
3576
3649
  const instructions = await instructionContext();
3577
- return context.json(authoringTools(scratchpadAvailable(instructions), instructions.traceEnabled));
3650
+ return context.json(authoringTools(instructions.traceEnabled));
3578
3651
  });
3579
3652
  app.get("/instructions", async (context) => {
3580
3653
  const { topic } = instructionsQuerySchema.parse(context.req.query());
@@ -3628,10 +3701,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3628
3701
  ...status(),
3629
3702
  desktopAvailable: (await capabilities()).desktopAvailable
3630
3703
  }));
3631
- app.get("/capabilities", async (context) => context.json({
3632
- ...await capabilities(),
3633
- scratchpadEnabled: scratchpadEnabled()
3634
- }));
3704
+ app.get("/capabilities", async (context) => context.json(await capabilities()));
3635
3705
  app.get("/:id/activity", (context) => {
3636
3706
  const id = context.req.param("id");
3637
3707
  readReview(id);
@@ -3653,6 +3723,9 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3653
3723
  action
3654
3724
  } : input));
3655
3725
  });
3726
+ 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))));
3727
+ app.post("/:id/focus", async (context) => context.json(store.focus(context.req.param("id"), await readBoundedRequestJson(context.req.raw))));
3728
+ app.post("/:id/discard", async (context) => context.json(await store.discard(context.req.param("id"))));
3656
3729
  app.get("/watch", (context) => {
3657
3730
  let input;
3658
3731
  try {
@@ -3669,6 +3742,16 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3669
3742
  let dirtyList = lists;
3670
3743
  const dirtyReviews = new Set(reviewIds);
3671
3744
  let sentList;
3745
+ const linksRevision = /* @__PURE__ */ new Map();
3746
+ const linksKey = (id) => {
3747
+ if (!data || isShared(id) || !store.has(id)) return "";
3748
+ const { outgoing, backlinks } = store.links(id);
3749
+ return JSON.stringify([...outgoing, ...backlinks].map((link) => link.pullRequestUrl && data.pullRequestState(link.pullRequestUrl, { seen: false })));
3750
+ };
3751
+ const linksKeys = new Map([...reviewIds].map((reviewId) => [reviewId, linksKey(reviewId)]));
3752
+ const placeRevision = /* @__PURE__ */ new Map();
3753
+ const placeKey = (id) => JSON.stringify(placeOf(id));
3754
+ const placeKeys = new Map([...reviewIds].map((reviewId) => [reviewId, placeKey(reviewId)]));
3672
3755
  const readReviewLine = (reviewId) => {
3673
3756
  try {
3674
3757
  return {
@@ -3677,7 +3760,9 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3677
3760
  value: {
3678
3761
  ...readReview(reviewId),
3679
3762
  activity: store.activity.read(reviewId),
3680
- coverageRevision: data?.coverageRevision ?? 0
3763
+ coverageRevision: data?.coverageRevision ?? 0,
3764
+ linksRevision: linksRevision.get(reviewId) ?? 0,
3765
+ placeRevision: placeRevision.get(reviewId) ?? 0
3681
3766
  }
3682
3767
  };
3683
3768
  } catch (error) {
@@ -3709,6 +3794,18 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3709
3794
  dirtyReviews.add(reviewId);
3710
3795
  return true;
3711
3796
  };
3797
+ /** Bumps the place of each board whose place changed. */
3798
+ const refreshPlaces = () => {
3799
+ let marked = false;
3800
+ for (const [id, key] of placeKeys) {
3801
+ const next = placeKey(id);
3802
+ if (next === key) continue;
3803
+ placeKeys.set(id, next);
3804
+ placeRevision.set(id, (placeRevision.get(id) ?? 0) + 1);
3805
+ marked = markReview(id) || marked;
3806
+ }
3807
+ return marked;
3808
+ };
3712
3809
  const markLists = () => {
3713
3810
  dirtyList ||= lists;
3714
3811
  return lists;
@@ -3721,7 +3818,12 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3721
3818
  notify();
3722
3819
  }) ?? (() => {}),
3723
3820
  store.subscribe((result) => {
3724
- if (markReview(result.reviewId)) notify();
3821
+ let marked = markReview(result.reviewId);
3822
+ for (const from of store.linkingBoards(result.reviewId)) {
3823
+ linksRevision.set(from, (linksRevision.get(from) ?? 0) + 1);
3824
+ marked = markReview(from) || marked;
3825
+ }
3826
+ if (marked) notify();
3725
3827
  }),
3726
3828
  store.activity.subscribe((id) => {
3727
3829
  if (markReview(id)) notify();
@@ -3729,11 +3831,24 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3729
3831
  store.activity.subscribeWorking(() => {
3730
3832
  if (markLists()) notify();
3731
3833
  }),
3834
+ data?.subscribePullRequestStates(() => {
3835
+ let marked = markLists();
3836
+ marked = refreshPlaces() || marked;
3837
+ for (const reviewId of reviewIds) {
3838
+ const next = linksKey(reviewId);
3839
+ if (next === linksKeys.get(reviewId)) continue;
3840
+ linksKeys.set(reviewId, next);
3841
+ linksRevision.set(reviewId, (linksRevision.get(reviewId) ?? 0) + 1);
3842
+ marked = markReview(reviewId) || marked;
3843
+ }
3844
+ if (marked) notify();
3845
+ }) ?? (() => {}),
3732
3846
  shared?.subscribe(() => {
3733
3847
  if (markLists()) notify();
3734
3848
  }) ?? (() => {}),
3735
3849
  store.subscribeCatalog(() => {
3736
- if (markLists()) notify();
3850
+ const marked = refreshPlaces();
3851
+ if (markLists() || marked) notify();
3737
3852
  })
3738
3853
  ];
3739
3854
  return () => stops.forEach((stop) => stop());
@@ -3789,19 +3904,18 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3789
3904
  storage: z.enum(["s3", "hosted"]).optional(),
3790
3905
  trace: z.string().min(1).optional()
3791
3906
  });
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;
3907
+ const tracePins = async (id, version) => {
3908
+ const snapshot = readReview(id, version);
3909
+ return snapshot.pins ?? await data.documentPins(snapshot);
3796
3910
  };
3797
3911
  app.get("/:id/agent-traces", async (context) => {
3798
3912
  const query = traceQuery.parse(context.req.query());
3799
- const pins = tracePins(context.req.param("id"), query.version);
3913
+ const pins = await tracePins(context.req.param("id"), query.version);
3800
3914
  return context.json(await listPinnedTraces(store.repositoryPath(pins.repositoryId), pins, query.storage));
3801
3915
  });
3802
3916
  app.get("/:id/agent-traces/:sessionId", async (context) => {
3803
3917
  const query = traceQuery.parse(context.req.query());
3804
- const pins = tracePins(context.req.param("id"), query.version);
3918
+ const pins = await tracePins(context.req.param("id"), query.version);
3805
3919
  const result = await readStoredTrace(store.repositoryPath(pins.repositoryId), context.req.param("sessionId"), query.trace, query.storage);
3806
3920
  if (!result.ok) return context.json({
3807
3921
  ok: false,
@@ -3844,7 +3958,16 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3844
3958
  const input = z.strictObject({ path: z.string().min(1) }).parse(await readBoundedRequestJson(context.req.raw));
3845
3959
  return context.json(await data.register(input.path));
3846
3960
  });
3847
- app.post("/resources", async (context) => context.json(await data.upload(await readBoundedRequestJson(context.req.raw, 8388608))));
3961
+ app.post("/resources", async (context) => {
3962
+ const body = await readBoundedRequestJson(context.req.raw, 8388608);
3963
+ const named = z.looseObject({ repositoryPath: z.string().min(1) }).safeParse(body);
3964
+ if (!named.success) return context.json(await data.upload(body));
3965
+ const { repositoryPath, ...upload } = named.data;
3966
+ return context.json(await data.upload({
3967
+ ...upload,
3968
+ repositoryId: (await data.register(repositoryPath)).id
3969
+ }));
3970
+ });
3848
3971
  app.get("/:id/resources/:resourceId", async (context) => {
3849
3972
  const id = context.req.param("id");
3850
3973
  const snapshot = readReview(id);
@@ -3951,6 +4074,11 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3951
4074
  const { pins } = await data.resolveSource(readReview(context.req.param("id"), input.version), input.commit, queryAnchor(input));
3952
4075
  return context.json(await data.changes(pins));
3953
4076
  });
4077
+ app.get("/:id/commit/:commit", async (context) => {
4078
+ const repositoryId = boardRepositoryId(readReview(context.req.param("id")));
4079
+ if (!repositoryId) throw new ReviewInputError("This board has no repository.", 404);
4080
+ return context.json(await data.commitInfo(repositoryId, context.req.param("commit")));
4081
+ });
3954
4082
  app.get("/:id/commits", async (context) => {
3955
4083
  const input = readQuerySchemas.commits.parse(context.req.query());
3956
4084
  return context.json(await data.commits((await data.resolveSource(readReview(context.req.param("id"), input.version))).pins));
@@ -3977,26 +4105,19 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
3977
4105
  });
3978
4106
  excerpt = `## ${target.side}: ${target.path}:${target.startLine}-${target.endLine} (${source.commit})\n` + source.text.split("\n").map((line) => ` ${line}`).join("\n");
3979
4107
  }
4108
+ if (target.kind === "block") excerpt = documentText(snapshot, target.blockId, true);
3980
4109
  const diff = selection.selectedDiff;
3981
4110
  const text = selectionMarkdown(selection, excerpt, diff ? {
3982
4111
  base: `a/${diff.oldPath}`,
3983
4112
  head: `b/${diff.newPath}`
3984
4113
  } : void 0);
3985
4114
  return [
3986
- `Whiteboard: ${snapshot.title}`,
4115
+ `Whiteboard: ${boardTitle(snapshot.title)}`,
3987
4116
  `Session ID: ${snapshot.reviewId}`,
3988
4117
  `Version: ${snapshot.version}`,
3989
4118
  ...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
- ] : [],
4119
+ ...selection.apiSource?.pins ? [...selection.apiSource.pins.base ? [`Selected base: ${selection.apiSource.pins.base}`] : [], `Selected head: ${selection.apiSource.pins.head}`] : [],
4120
+ ...snapshot.pins ? [`Session base: ${snapshot.pins.base}`, `Session head: ${snapshot.pins.head}`] : [],
4000
4121
  `Read this version with session_get({"sessionId":"${snapshot.reviewId}","version":${snapshot.version},"full":true}).`,
4001
4122
  "",
4002
4123
  text,
@@ -4005,9 +4126,10 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4005
4126
  ].join("\n");
4006
4127
  }
4007
4128
  if (ask && data) {
4129
+ const projectOf = (reviewId) => isShared(reviewId) || !store.has(reviewId) ? void 0 : store.projectOf(reviewId);
4008
4130
  const readThread = (reviewId, threadId) => {
4009
4131
  const thread = ask.threads.get(threadId);
4010
- if (!thread || thread.reviewId !== reviewId) throw new ReviewInputError("This conversation has ended.", 404);
4132
+ if (!thread) throw new ReviewInputError("This conversation has ended.", 404);
4011
4133
  return thread;
4012
4134
  };
4013
4135
  /** Where a thread in a review saves itself: its Ask history, recorded
@@ -4057,7 +4179,7 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4057
4179
  const reach = await ask.threads.reach(agent, threadId);
4058
4180
  const { reviewId } = snapshot;
4059
4181
  return [
4060
- `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
4182
+ `A reviewer is reading "${boardTitle(snapshot.title)}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
4061
4183
  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
4184
  "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
4185
  "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 +4230,17 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4108
4230
  const permissions = input.permissions ?? defaultAskPermissions(input.agent);
4109
4231
  const initialContext = await askContext(id, input.agent, snapshot, checkout, input.selection, version);
4110
4232
  assertWritable(reviewId);
4233
+ store.askHistory.markViewed(id, createdAt);
4111
4234
  const thread = ask.threads.open({
4112
4235
  id,
4113
4236
  reviewId,
4114
4237
  agent: input.agent,
4115
4238
  ...historyHooks({
4116
4239
  id,
4117
- reviewId,
4240
+ anchor: target.kind === "text" && target.anchor ? {
4241
+ board: reviewId,
4242
+ block: target.anchor.blockId
4243
+ } : { board: reviewId },
4118
4244
  agent: input.agent,
4119
4245
  version: snapshot.version,
4120
4246
  head: checkout.head,
@@ -4138,23 +4264,106 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4138
4264
  });
4139
4265
  return context.json({ threadId: thread.id });
4140
4266
  });
4267
+ /** Every project's threads for the sidebar: who runs them, where they
4268
+ * started and live, and which boards they changed. */
4269
+ const projectThreads = (reviewId) => {
4270
+ const entries = store.askHistory.listAll();
4271
+ const ids = entries.map((entry) => entry.id);
4272
+ const edited = store.boardsChangedByThreads(ids);
4273
+ const boards = /* @__PURE__ */ new Map();
4274
+ const board = (id) => {
4275
+ let found = boards.get(id);
4276
+ if (!found) {
4277
+ found = store.has(id) ? store.boardFace(id) : {
4278
+ reviewId: id,
4279
+ icon: boardIconKind(void 0)
4280
+ };
4281
+ boards.set(id, found);
4282
+ }
4283
+ return found;
4284
+ };
4285
+ const project = projectOf(reviewId);
4286
+ return {
4287
+ board: reviewId,
4288
+ ...project && { project },
4289
+ threads: entries.map((entry) => {
4290
+ const anchor = entry.anchor ?? { board: reviewId };
4291
+ const status = ask.threads.get(entry.id)?.status;
4292
+ const { viewedAt } = entry;
4293
+ const unread = status !== "starting" && status !== "running" && status !== "waiting" && viewedAt !== void 0 && entry.updatedAt > viewedAt;
4294
+ return {
4295
+ id: entry.id,
4296
+ ...entry.projectId && { projectId: entry.projectId },
4297
+ ...store.has(anchor.board) && { place: store.boardPlace(anchor.board) },
4298
+ agent: entry.agent,
4299
+ agentName: askAgents[entry.agent].name,
4300
+ opening: entry.question ?? entry.title,
4301
+ pinned: entry.selection.target.kind === "text" && entry.selection.target.anchor !== void 0,
4302
+ ...status && { status },
4303
+ ...unread && { unread },
4304
+ updatedAt: entry.updatedAt,
4305
+ anchor: {
4306
+ board: board(anchor.board),
4307
+ ...anchor.block && { block: anchor.block }
4308
+ },
4309
+ edited: edited.get(entry.id) ?? []
4310
+ };
4311
+ })
4312
+ };
4313
+ };
4141
4314
  app.get("/:id/ask/threads", (context) => {
4142
4315
  const reviewId = context.req.param("id");
4143
4316
  readReview(reviewId);
4144
- return context.json({ threads: store.askHistory.list(reviewId) });
4317
+ if (context.req.query("watch") !== void 0) {
4318
+ let sent = "";
4319
+ return watch(() => {
4320
+ const line = JSON.stringify(projectThreads(reviewId));
4321
+ if (line === sent) return [];
4322
+ sent = line;
4323
+ return [line];
4324
+ }, (notify) => {
4325
+ let timer;
4326
+ const later = () => {
4327
+ timer ??= setTimeout(() => {
4328
+ timer = void 0;
4329
+ notify();
4330
+ }, 100);
4331
+ };
4332
+ const stops = [
4333
+ store.askHistory.subscribe(later),
4334
+ ask.threads.subscribeStatus(later),
4335
+ store.subscribe((change) => {
4336
+ if (change.deleted || change.lifecycle || change.titleChanged || change.placeChanged || change.threadId || change.external) later();
4337
+ })
4338
+ ];
4339
+ return () => {
4340
+ clearTimeout(timer);
4341
+ stops.forEach((stop) => stop());
4342
+ };
4343
+ });
4344
+ }
4345
+ return context.json({
4346
+ threads: store.askHistory.list(reviewId, projectOf(reviewId)),
4347
+ board: reviewId
4348
+ });
4145
4349
  });
4146
4350
  app.post("/:id/ask/watch", reviewAccess("read"), async (context) => {
4147
4351
  const reviewId = context.req.param("id");
4148
4352
  readReview(reviewId);
4149
4353
  const { threads } = askWatchSchema.parse(await readBoundedRequestJson(context.req.raw));
4150
4354
  return watchAskThreads(new Map(threads.map((threadId) => {
4151
- const thread = ask.threads.get(threadId);
4152
- return [threadId, thread?.reviewId === reviewId ? thread : void 0];
4355
+ return [threadId, ask.threads.get(threadId)];
4153
4356
  })));
4154
4357
  });
4358
+ app.get("/:id/ask/:threadId/boards", (context) => {
4359
+ const threadId = context.req.param("threadId");
4360
+ const anchor = store.askHistory.get(threadId)?.anchor.board ?? ask.threads.get(threadId)?.reviewId;
4361
+ if (remoteCaller(context) || !anchor) throw new ReviewInputError("This conversation was not found.", 404);
4362
+ return context.json({ boards: store.boardsChangedBy(threadId) });
4363
+ });
4155
4364
  app.get("/:id/ask/:threadId", (context) => {
4156
4365
  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);
4366
+ if (!record) throw new ReviewInputError("This conversation was not found.", 404);
4158
4367
  const target = record.selection.target;
4159
4368
  return context.json({
4160
4369
  id: record.id,
@@ -4176,18 +4385,19 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4176
4385
  app.post("/:id/ask/:threadId/open", reviewAccess("edit"), async (context) => {
4177
4386
  const reviewId = context.req.param("id");
4178
4387
  const threadId = context.req.param("threadId");
4179
- if (ask.threads.get(threadId)?.reviewId === reviewId) return context.json({ threadId });
4388
+ if (ask.threads.get(threadId)) return context.json({ threadId });
4180
4389
  const record = store.askHistory.get(threadId);
4181
4390
  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);
4391
+ if (!record) throw new ReviewInputError("This conversation was not found.", 404);
4392
+ const anchor = record.anchor.board;
4393
+ const snapshot = readReview(anchor, record.version);
4184
4394
  const checkout = await data.agentCheckout(snapshot);
4185
4395
  const target = record.selection.target;
4186
4396
  const initialContext = await askContext(record.id, record.agent, snapshot, checkout, record.selection, record.version);
4187
4397
  assertWritable(reviewId);
4188
4398
  ask.threads.open({
4189
4399
  id: record.id,
4190
- reviewId,
4400
+ reviewId: anchor,
4191
4401
  agent: record.agent,
4192
4402
  cwd: checkout.rootPath,
4193
4403
  head: checkout.head,
@@ -4263,27 +4473,91 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4263
4473
  await readThread(context.req.param("id"), context.req.param("threadId")).cancel();
4264
4474
  return context.json({ ok: true });
4265
4475
  });
4476
+ app.post("/:id/ask/:threadId/viewed", (context) => {
4477
+ const threadId = context.req.param("threadId");
4478
+ if (!ask.threads.get(threadId) && !store.askHistory.get(threadId)) throw new ReviewInputError("This conversation was not found.", 404);
4479
+ store.askHistory.markViewed(threadId);
4480
+ return context.json({ ok: true });
4481
+ });
4266
4482
  app.post("/:id/ask/:threadId/close", reviewAccess("read"), (context) => {
4267
4483
  readThread(context.req.param("id"), context.req.param("threadId"));
4268
4484
  ask.threads.close(context.req.param("threadId"), !readReview(context.req.param("id")).trashedAt);
4269
4485
  return context.json({ ok: true });
4270
4486
  });
4271
4487
  app.delete("/:id/ask/:threadId", reviewAccess("edit"), (context) => {
4272
- const reviewId = context.req.param("id");
4488
+ context.req.param("id");
4273
4489
  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);
4490
+ if (!store.askHistory.get(threadId)) throw new ReviewInputError("This conversation was not found.", 404);
4491
+ if (ask.threads.get(threadId)) ask.threads.close(threadId);
4276
4492
  store.askHistory.delete(threadId);
4277
4493
  return context.json({ ok: true });
4278
4494
  });
4279
4495
  }
4496
+ app.get("/:id/place", (context) => {
4497
+ const id = context.req.param("id");
4498
+ if (!isShared(id)) store.assertExists(id);
4499
+ return context.json(placeOf(id));
4500
+ });
4280
4501
  app.get("/:id/stack", async (context) => {
4281
4502
  const query = readQuerySchemas.get.parse(context.req.query());
4282
4503
  const id = context.req.param("id");
4283
4504
  const snapshot = readReview(id, query.version);
4284
4505
  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 });
4506
+ const repositoryPath = store.repositories().find((repository) => repository.id === snapshot.pins.repositoryId)?.path;
4507
+ const layers = await resolveReviewStackLayers(snapshot, store.list(), { repositoryPath });
4508
+ const trunk = layers.length > 1 && repositoryPath ? await defaultBranch(repositoryPath).catch(() => null) : null;
4509
+ return context.json({
4510
+ layers: layers.map((layer) => {
4511
+ const state = data?.pullRequestState(layer.pullRequestUrl ?? void 0);
4512
+ return state ? {
4513
+ ...layer,
4514
+ pullRequestState: state
4515
+ } : layer;
4516
+ }),
4517
+ ...trunk && { base: trunk.ref.replace(/^origin\//, "") }
4518
+ });
4519
+ });
4520
+ app.get("/:id/project", (context) => {
4521
+ const id = context.req.param("id");
4522
+ if (isShared(id)) return context.json({
4523
+ project: null,
4524
+ boards: []
4525
+ });
4526
+ const project = store.summary(id)?.project ?? null;
4527
+ return context.json({
4528
+ project,
4529
+ boards: project ? store.listProject(project.id).filter((board) => !board.trashedAt).map(withPullRequestState) : []
4530
+ });
4531
+ });
4532
+ app.get("/:id/links", (context) => {
4533
+ const id = context.req.param("id");
4534
+ if (isShared(id)) return context.json({
4535
+ outgoing: boardLinks(id, readReview(id).document).map(({ from, to }) => ({
4536
+ from,
4537
+ to,
4538
+ missing: true,
4539
+ elsewhere: true
4540
+ })),
4541
+ backlinks: []
4542
+ });
4543
+ const { version } = readQuerySchemas.get.pick({ version: true }).parse({ version: context.req.query("version") });
4544
+ const { outgoing, backlinks } = store.links(id, version);
4545
+ return context.json({
4546
+ outgoing: outgoing.map(withPullRequestState),
4547
+ backlinks: backlinks.map(withPullRequestState)
4548
+ });
4549
+ });
4550
+ app.get("/:id/links/peek", (context) => {
4551
+ const id = context.req.param("id");
4552
+ if (isShared(id)) return context.json({});
4553
+ const { version } = readQuerySchemas.get.pick({ version: true }).parse({ version: context.req.query("version") });
4554
+ const board = context.req.query("board");
4555
+ const block = context.req.query("block");
4556
+ if (!board) throw new ReviewInputError("Name the linked board.", 400);
4557
+ return context.json(store.linkPeek(id, {
4558
+ board,
4559
+ ...block && { block }
4560
+ }, version));
4287
4561
  });
4288
4562
  app.get("/:id/history", (context) => {
4289
4563
  const id = context.req.param("id");
@@ -4292,14 +4566,39 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4292
4566
  return context.json([{
4293
4567
  version: snapshot.version,
4294
4568
  title: snapshot.title,
4295
- createdAt: snapshot.createdAt
4569
+ createdAt: snapshot.createdAt,
4570
+ author: "agent"
4296
4571
  }]);
4297
4572
  });
4573
+ /** A board's relations as session_list reports them: only those it has. */
4574
+ const listedRelations = ({ parentId, children, dependsOn }) => {
4575
+ const listed = {};
4576
+ if (parentId) listed.parentId = parentId;
4577
+ if (children.length) listed.children = children;
4578
+ if (dependsOn.length) listed.dependsOn = dependsOn;
4579
+ return listed;
4580
+ };
4581
+ /** A board's relations, each board named by its title. */
4582
+ const relatedBoards = (id) => {
4583
+ const { parentId, children, dependsOn } = store.relations(id);
4584
+ const named = (board) => ({
4585
+ id: board,
4586
+ title: store.read(board).title
4587
+ });
4588
+ return {
4589
+ ...parentId && { parent: named(parentId) },
4590
+ children: children.map(named),
4591
+ dependsOn: dependsOn.map(named)
4592
+ };
4593
+ };
4298
4594
  app.get("/:id/inspect", (context) => {
4299
4595
  const query = inspectQuerySchema.parse(context.req.query());
4300
4596
  const id = context.req.param("id");
4301
4597
  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));
4598
+ 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) : {
4599
+ ...query.full ? snapshot : inspectSnapshot(snapshot),
4600
+ ...!isShared(id) && listedRelations(store.relations(id))
4601
+ });
4303
4602
  });
4304
4603
  app.get("/:id", async (context) => {
4305
4604
  const query = readQuerySchemas.get.parse(context.req.query());
@@ -4326,17 +4625,13 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4326
4625
  };
4327
4626
  app.post("/commands", async (context) => {
4328
4627
  const { command: body, open: requestedOpen } = takeCreateOpen(await readBoundedRequestJson(context.req.raw));
4329
- const request = await locateRepositories(body, (path) => {
4628
+ const register = async (path) => data ? data.register(path) : store.registerRepository(path);
4629
+ const request = withBoardRepository(await locateRepositories(await pullRequestTargets(body, register, (url, repositoryId) => {
4330
4630
  if (!data) throw new ReviewInputError("Repositories are unavailable.");
4331
- return data.register(path);
4332
- });
4631
+ return data.resolvePullRequest(url, { id: repositoryId });
4632
+ }, (id) => store.read(id)), register), (id) => store.has(id) ? store.read(id) : void 0);
4333
4633
  const input = commandSchema.parse(request);
4334
4634
  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
4635
  if (command.success && command.data.operation.reviewId?.startsWith("shared-")) {
4341
4636
  const parsed = commandSchema.parse(input);
4342
4637
  if (shared && parsed.operation.type === "delete") {
@@ -4351,8 +4646,6 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4351
4646
  if (shared && (parsed.operation.type === "attention" || parsed.operation.type === "lifecycle")) {
4352
4647
  if (parsed.operation.type === "lifecycle") await shared.setLifecycle(parsed.operation.reviewId, parsed.operation.state);
4353
4648
  else await shared.setAttention(parsed.operation.reviewId);
4354
- const reviewId = parsed.operation.reviewId;
4355
- if (shared.get(reviewId).snapshot.trashedAt) ask?.threads.closeDeletedReviews((id) => id === reviewId);
4356
4649
  return context.json({
4357
4650
  reviewId: parsed.operation.reviewId,
4358
4651
  version: shared.get(parsed.operation.reviewId).snapshot.version,
@@ -4361,8 +4654,12 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4361
4654
  }
4362
4655
  throw new ReviewInputError("Shared reviews are read-only.", 409);
4363
4656
  }
4364
- const result = await store.execute(input);
4365
- if (result.deleted || result.lifecycle && store.read(result.reviewId).trashedAt) ask?.threads.closeDeletedReviews((id) => id === result.reviewId);
4657
+ const threadId = remoteCaller(context) ? void 0 : context.req.header("x-review-thread")?.trim() || void 0;
4658
+ const author = !remoteCaller(context) && context.req.header("x-review-person") !== void 0 ? { kind: "person" } : {
4659
+ kind: "agent",
4660
+ ...threadId && { threadId }
4661
+ };
4662
+ const result = await store.execute(input, void 0, author);
4366
4663
  if (input.operation.type === "edit" && data && !result.deleted) {
4367
4664
  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);
4368
4665
  return context.json({
@@ -4382,7 +4679,6 @@ function createReviewApi(store, data, open, shared, capabilities = () => ({
4382
4679
  if (input.operation.type !== "create") return context.json(result);
4383
4680
  if (result.created !== false) hooks.onReviewCreated?.({
4384
4681
  reviewId: result.reviewId,
4385
- kind: input.operation.kind === "scratchpad" ? "scratchpad" : "review",
4386
4682
  blocks: store.read(result.reviewId).document.length,
4387
4683
  ...reviewRequestOrigin(context.req.raw.headers)
4388
4684
  });
@@ -4446,11 +4742,102 @@ async function locateRepositories(body, register) {
4446
4742
  }
4447
4743
  };
4448
4744
  }
4745
+ /** Turns an agent's pullRequest target into what the store takes: a create
4746
+ * from pullRequestUrl, or for set_target the PR's resolved commits. */
4747
+ async function pullRequestTargets(body, register, resolve, board) {
4748
+ const target = z.strictObject({
4749
+ kind: z.literal("pullRequest"),
4750
+ repositoryPath: z.string().min(1),
4751
+ pullRequest: z.union([z.string().min(1), z.number().int().positive()])
4752
+ });
4753
+ const located = z.looseObject({ operation: z.discriminatedUnion("type", [z.looseObject({
4754
+ type: z.literal("create"),
4755
+ target
4756
+ }), z.strictObject({
4757
+ type: z.literal("set_target"),
4758
+ reviewId: z.string().min(1),
4759
+ target
4760
+ })]) }).safeParse(body);
4761
+ if (!located.success) return body;
4762
+ const { target: pullRequest, ...operation } = located.data.operation;
4763
+ if (operation.type === "set_target") board(operation.reviewId);
4764
+ const { id: repositoryId } = await register(pullRequest.repositoryPath);
4765
+ const url = await pullRequestAt(pullRequest.repositoryPath, pullRequest.pullRequest);
4766
+ if (operation.type === "create") return {
4767
+ ...located.data,
4768
+ operation: {
4769
+ ...operation,
4770
+ pullRequestUrl: url,
4771
+ repositoryId
4772
+ }
4773
+ };
4774
+ return {
4775
+ ...located.data,
4776
+ operation: {
4777
+ ...operation,
4778
+ target: (await resolve(url, repositoryId)).target,
4779
+ pullRequestUrl: url
4780
+ }
4781
+ };
4782
+ }
4783
+ /** A PR's URL: as given, or its number on the checkout's GitHub repository. */
4784
+ async function pullRequestAt(repositoryPath, pullRequest) {
4785
+ if (isStringValue(pullRequest) && !/^\d+$/.test(pullRequest)) return pullRequest;
4786
+ const slug = (await resolveRepoContext(repositoryPath))?.githubSlug;
4787
+ if (!slug) throw new ReviewInputError("This checkout has no GitHub remote to find the PR number in; pass the PR's URL.");
4788
+ return `https://github.com/${slug}/pull/${pullRequest}`;
4789
+ }
4790
+ /** A pins object as an edit spells it: commits, with no other fields. A
4791
+ * diagram may key its own data `pins`, and that is left alone. */
4792
+ /** Pins as an agent writes them: the board's repository is the host's to
4793
+ * add. */
4794
+ const agentPinsSchema = z.strictObject({
4795
+ head: z.string().min(1),
4796
+ base: z.string().min(1).optional()
4797
+ });
4798
+ /** An object an agent meant as pins: one naming a head, base or repository.
4799
+ * Content may have a field called `pins` that is nothing of the kind. */
4800
+ const meantAsPins = (value) => isJsonObject(value) && [
4801
+ "head",
4802
+ "base",
4803
+ "repositoryId"
4804
+ ].some((key) => key in value);
4805
+ /** Sets every pins object in an edit to the board's own repository. */
4806
+ function withBoardRepository(body, board) {
4807
+ const located = z.looseObject({ operation: z.looseObject({
4808
+ type: z.enum(["edit", "lens_edit"]),
4809
+ reviewId: z.string(),
4810
+ edit: z.json()
4811
+ }) }).safeParse(body);
4812
+ if (!located.success) return body;
4813
+ const snapshot = board(located.data.operation.reviewId);
4814
+ const repositoryId = snapshot ? boardRepositoryId(snapshot) : void 0;
4815
+ const edit = JSON.parse(JSON.stringify(located.data.operation.edit), (key, value) => {
4816
+ if (key !== "pins" || !meantAsPins(value)) return value;
4817
+ const { repositoryId: _ignored, ...written } = value;
4818
+ const pins = agentPinsSchema.safeParse(written);
4819
+ 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("; ")}.`);
4820
+ if (!repositoryId) throw new ReviewInputError("This whiteboard has no repository for its pins to name commits of.");
4821
+ return {
4822
+ ...pins.data,
4823
+ repositoryId
4824
+ };
4825
+ });
4826
+ return {
4827
+ ...located.data,
4828
+ operation: {
4829
+ ...located.data.operation,
4830
+ edit
4831
+ }
4832
+ };
4833
+ }
4449
4834
  function watch(lines, subscribe) {
4450
4835
  let stop = () => {};
4836
+ let missed = false;
4451
4837
  const encoder = new TextEncoder();
4452
4838
  const send = (controller) => {
4453
- if (controller.desiredSize === null || controller.desiredSize <= 0) return;
4839
+ missed = controller.desiredSize === null || controller.desiredSize <= 0;
4840
+ if (missed) return;
4454
4841
  try {
4455
4842
  const pending = lines();
4456
4843
  if (pending.length) controller.enqueue(encoder.encode(pending.join("\n") + "\n"));
@@ -4472,7 +4859,9 @@ function watch(lines, subscribe) {
4472
4859
  });
4473
4860
  send(controller);
4474
4861
  },
4475
- pull: send,
4862
+ pull(controller) {
4863
+ if (missed) send(controller);
4864
+ },
4476
4865
  cancel() {
4477
4866
  stop();
4478
4867
  }
@@ -4614,7 +5003,7 @@ function createWhiteboardCore(input) {
4614
5003
  if (result.deleted || result.lifecycle) closeDeletedThreads();
4615
5004
  });
4616
5005
  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 && {
5006
+ const api = createReviewApi(store, data, callbacks.open, shared, callbacks.capabilities, () => traceMachineEnabled(), input.status, input.hooks, askThreads && {
4618
5007
  threads: askThreads,
4619
5008
  agents: async () => detectAskAgents(await ask?.env)
4620
5009
  });
@@ -4903,7 +5292,6 @@ async function serve(input) {
4903
5292
  token: discovery.token,
4904
5293
  instanceId: discovery.instanceId,
4905
5294
  softwareMapEnabled: input.softwareMapEnabled,
4906
- scratchpad: () => false,
4907
5295
  status: () => ({
4908
5296
  key: "headless",
4909
5297
  home: input.stateDir