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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/THIRD_PARTY_NOTICES.md +29 -0
  2. package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
  3. package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
  4. package/dist/agent-cli-CRPPmS3l.js +2 -0
  5. package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
  6. package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
  7. package/dist/background-server-Cw7CcfaZ.js +156 -0
  8. package/dist/background-server-D2Rrr_e2.js +2 -0
  9. package/dist/build-info.json +1 -1
  10. package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
  11. package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
  12. package/dist/cli.js +3 -2
  13. package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
  14. package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
  15. package/dist/headless-host-Cp37C-v0.js +2 -0
  16. package/dist/input-error-OLgB_h31.js +11 -0
  17. package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
  18. package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
  19. package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
  20. package/dist/profile-BSOkA6A-.js +2 -0
  21. package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
  22. package/dist/remote-attach-BAt2eZx3.js +137 -0
  23. package/dist/remote-extensions-2Cxf3hMo.js +2 -0
  24. package/dist/remote-extensions-BiNx6NQK.js +323 -0
  25. package/dist/remote-language-server-DQgWUNeV.js +252 -0
  26. package/dist/remote-uninstall-DLtDG0o7.js +153 -0
  27. package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
  28. package/dist/review-app-launcher-D7I4DGoH.js +222 -0
  29. package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
  30. package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
  31. package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
  32. package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
  33. package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
  34. package/dist/server/desktop-host.js +1663 -266
  35. package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
  36. package/dist/sharing/index.d.ts +88 -32
  37. package/dist/sharing/index.js +1 -1
  38. package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
  39. package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
  40. package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
  41. package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
  42. package/instructions/authoring.md +16 -12
  43. package/instructions/file-lenses.md +4 -2
  44. package/package.json +9 -14
  45. package/src/agent-connect.ts +507 -0
  46. package/src/agent-selection.ts +4 -0
  47. package/src/ask/agents.ts +59 -13
  48. package/src/ask/pi-mcp.ts +7 -3
  49. package/src/ask/thread-state.ts +9 -4
  50. package/src/ask/thread.ts +88 -50
  51. package/src/ask/threads.ts +53 -7
  52. package/src/cli-install.ts +3 -1
  53. package/src/cli-runner.ts +472 -81
  54. package/src/cli.ts +1 -1
  55. package/src/connect-agents.ts +285 -0
  56. package/src/connect-prompts.ts +60 -6
  57. package/src/desktop-discovery.ts +24 -0
  58. package/src/pinned-download.ts +118 -0
  59. package/src/remote-attach.ts +130 -0
  60. package/src/remote-extensions.ts +359 -0
  61. package/src/remote-language-server.ts +428 -0
  62. package/src/remote-toolchains.ts +107 -0
  63. package/src/remote-uninstall.ts +280 -0
  64. package/src/review-api/README.md +1 -3
  65. package/src/review-api/agent-cli.ts +4 -4
  66. package/src/review-api/agent-client.ts +23 -0
  67. package/src/review-api/ask-history.ts +31 -16
  68. package/src/review-api/authoring-tools.ts +1 -1
  69. package/src/review-api/blocks/index.ts +2 -4
  70. package/src/review-api/document.ts +1 -4
  71. package/src/review-api/http.ts +265 -237
  72. package/src/review-api/local-data.ts +18 -3
  73. package/src/review-api/onboarding.ts +478 -0
  74. package/src/review-api/origin.ts +7 -0
  75. package/src/review-api/recent-folders.ts +173 -0
  76. package/src/review-api/store.ts +56 -10
  77. package/src/review-api/workspaces.ts +56 -11
  78. package/src/review-app-launcher.ts +61 -3
  79. package/src/review-stack.ts +162 -36
  80. package/src/server/background-server.ts +250 -0
  81. package/src/server/desktop-host-shutdown.ts +24 -0
  82. package/src/server/desktop-host.ts +39 -24
  83. package/src/server/desktop-server.ts +68 -118
  84. package/src/server/global-verb-relay.ts +16 -0
  85. package/src/server/headless-host.ts +128 -3
  86. package/src/server/hono-http.ts +6 -1
  87. package/src/server/legacy-tutorial-cleanup.ts +38 -0
  88. package/src/server/review-gateway-hosts.ts +379 -0
  89. package/src/server/review-gateway-list.ts +109 -0
  90. package/src/server/review-gateway-memory.ts +130 -0
  91. package/src/server/review-gateway-pushes.ts +142 -0
  92. package/src/server/review-gateway-streams.ts +415 -0
  93. package/src/server/review-gateway-transport.ts +204 -0
  94. package/src/server/review-gateway.ts +433 -0
  95. package/src/server/review-server-core.ts +36 -6
  96. package/src/server-discovery.ts +11 -0
  97. package/src/sharing/import.ts +1 -0
  98. package/src/ui-telemetry-events.ts +5 -1
  99. package/src/vsix.ts +122 -0
  100. package/dist/agent-cli-BR_Jw5BL.js +0 -2
  101. package/dist/headless-host-BLLJ7Dyu.js +0 -95
  102. package/dist/profile-aMSw1oCM.js +0 -2
  103. package/dist/server-discovery-ZVi8Ay8J.js +0 -66
  104. package/src/fixtures/blocks/tutorial.json +0 -14
  105. package/src/review-api/blocks/tutorial.ts +0 -23
  106. package/src/server/tutorial-service.ts +0 -217
  107. package/tutorial/document.json +0 -216
  108. package/tutorial/lenses.json +0 -10
  109. package/tutorial/runtime-manifest.json +0 -11
  110. package/tutorial/sample-service/package.json +0 -9
  111. package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
  112. package/tutorial/sample-service/src/app.ts +0 -26
  113. package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
  114. package/tutorial/sample-service/src/database/schema.ts +0 -9
  115. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
  116. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
  117. package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
  118. package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
  119. package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
  120. package/tutorial/sample-service/src/orders/order-service.ts +0 -35
  121. package/tutorial/sample-service/src/orders/order.ts +0 -43
  122. package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
  123. package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
  124. package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
  125. package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
  126. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
  127. package/tutorial/sample-service/tsconfig.json +0 -12
@@ -1,9 +1,10 @@
1
- import { createHash } from "node:crypto";
2
-
3
1
  import { type JsonObject, isJsonObject } from "@dev.fast/json";
4
2
  import {
5
3
  REVIEW_CLIENT_HEADER,
6
4
  REVIEW_CLIENT_REMOTE,
5
+ type ReviewRemoteLanguageEnvironment,
6
+ type ReviewRemoteNavigatorAnswer,
7
+ type ReviewStreamLine,
7
8
  type ReviewStructuralDiffEvent,
8
9
  } from "@dev.fast/review-protocol";
9
10
  import { errorMessage } from "@dev.fast/trace-core";
@@ -12,7 +13,11 @@ import {
12
13
  AgentSelectionSchema,
13
14
  selectionMarkdown,
14
15
  } from "@review/agent-selection.js";
15
- import { type AskAgentStatus, askAgents } from "@review/ask/agents.js";
16
+ import {
17
+ type AskAgentStatus,
18
+ askAgents,
19
+ defaultAskPermissions,
20
+ } from "@review/ask/agents.js";
16
21
  import { checkoutFiles, mentionableFiles } from "@review/ask/checkout-files.js";
17
22
  import { parseFileRef, resolveFileRefs } from "@review/ask/file-refs.js";
18
23
  import {
@@ -20,9 +25,11 @@ import {
20
25
  type AskThreadState,
21
26
  askAgentIds,
22
27
  askChoiceKinds,
28
+ askPermissionsSchema,
23
29
  askPicksSchema,
24
30
  askQuestionSchema,
25
31
  } from "@review/ask/thread-state.js";
32
+ import type { AskThreadHooks } from "@review/ask/thread.js";
26
33
  import type { AskThreads } from "@review/ask/threads.js";
27
34
  import { watchAskThreads } from "@review/ask/watch.js";
28
35
  import { fuzzyRank } from "@review/fuzzy-match.js";
@@ -41,6 +48,7 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
41
48
  import { z } from "zod";
42
49
 
43
50
  import { anchorQuotes } from "./anchor-quotes.js";
51
+ import type { AskRecord } from "./ask-history.js";
44
52
  import { authoringTools } from "./authoring-tools.js";
45
53
  import { documentText } from "./document-text.js";
46
54
  import { ReviewInputError } from "./document.js";
@@ -50,6 +58,7 @@ import {
50
58
  scratchpadAvailable,
51
59
  } from "./instructions.js";
52
60
  import type { LocalReviewData } from "./local-data.js";
61
+ import { registerOnboardingRoutes } from "./onboarding.js";
53
62
  import {
54
63
  inspectQuerySchema,
55
64
  queryAnchor,
@@ -86,10 +95,10 @@ const askStartSchema = z.strictObject({
86
95
  question: askQuestionSchema,
87
96
  selection: AgentSelectionSchema,
88
97
  picks: askPicksSchema.optional(),
89
- bypass: z.boolean().optional(),
98
+ permissions: askPermissionsSchema.optional(),
90
99
  });
91
100
 
92
- const askPermitSchema = z.strictObject({ bypass: z.boolean() });
101
+ const askPermitSchema = z.strictObject({ permissions: askPermissionsSchema });
93
102
 
94
103
  /** The threads one watch follows: those of a review's open Asks. A body,
95
104
  * not the query, so there can be as many as are open. */
@@ -155,10 +164,6 @@ export interface ReviewApiHooks {
155
164
  sharing?: SharingHostEvents;
156
165
  }
157
166
 
158
- /** A gateway forwarding from another machine; it gets no local paths. */
159
- const remoteCaller = (context: Context) =>
160
- context.req.header(REVIEW_CLIENT_HEADER) === REVIEW_CLIENT_REMOTE;
161
-
162
167
  /** Both hosts mount this behind their token authentication. */
163
168
  export function createReviewApi(
164
169
  store: ReviewStore,
@@ -509,121 +514,123 @@ export function createReviewApi(
509
514
  );
510
515
  },
511
516
  );
512
- app.get("/watch", async (context) => {
513
- const query = context.req.query("subscriptions");
517
+ app.get("/watch", (context) => {
518
+ let input: unknown;
519
+
520
+ try {
521
+ input = JSON.parse(context.req.query("subscriptions") ?? "");
522
+ } catch {
523
+ throw new ReviewInputError("Invalid subscriptions.");
524
+ }
525
+
526
+ const subscriptions = z
527
+ .array(
528
+ z.strictObject({
529
+ reviewId: z.string().min(1).nullable(),
530
+ mode: coverageModeSchema,
531
+ }),
532
+ )
533
+ .parse(input);
534
+
535
+ const modes = new Set(
536
+ subscriptions.flatMap((item) =>
537
+ item.reviewId === null ? [item.mode] : [],
538
+ ),
539
+ );
514
540
 
515
- if (query !== undefined) {
516
- let input: unknown;
541
+ const reviewIds = new Set(
542
+ subscriptions.flatMap((item) => item.reviewId ?? []),
543
+ );
544
+
545
+ const dirtyModes = new Set(modes);
546
+ const dirtyReviews = new Set(reviewIds);
547
+ const sentLists = new Map<string, string>();
517
548
 
549
+ const readReviewLine = (reviewId: string): ReviewStreamLine<Snapshot> => {
518
550
  try {
519
- input = JSON.parse(query);
520
- } catch {
521
- throw new ReviewInputError("Invalid subscriptions.");
551
+ return {
552
+ kind: "review",
553
+ reviewId,
554
+ value: {
555
+ ...readReview(reviewId),
556
+ activity: store.activity.read(reviewId),
557
+ coverageRevision: data?.coverageRevision ?? 0,
558
+ },
559
+ };
560
+ } catch (error) {
561
+ return {
562
+ kind: "review",
563
+ reviewId,
564
+ error:
565
+ error instanceof ReviewInputError
566
+ ? error.message
567
+ : "Could not read review.",
568
+ };
522
569
  }
570
+ };
523
571
 
524
- const subscriptions = z
525
- .array(
526
- z.strictObject({
527
- reviewId: z.string().min(1).nullable(),
528
- mode: coverageModeSchema,
529
- }),
530
- )
531
- .parse(input);
572
+ const lines = () => {
573
+ const pending: string[] = [];
532
574
 
533
- // Only entries whose review (or the catalog) changed are re-read and re-sent.
534
- const dirty = new Set(subscriptions.keys());
575
+ for (const mode of dirtyModes) {
576
+ const line = JSON.stringify({
577
+ kind: "list",
578
+ mode,
579
+ reviews: catalog(mode),
580
+ } satisfies ReviewStreamLine);
535
581
 
536
- const mark = (id: string | null) => {
537
- let marked = false;
582
+ if (sentLists.get(mode) !== line) pending.push(line);
583
+ sentLists.set(mode, line);
584
+ }
538
585
 
539
- subscriptions.forEach((item, index) => {
540
- if (item.reviewId === id) {
541
- dirty.add(index);
542
- marked = true;
543
- }
544
- });
586
+ for (const reviewId of dirtyReviews)
587
+ pending.push(JSON.stringify(readReviewLine(reviewId)));
545
588
 
546
- return marked;
547
- };
589
+ dirtyModes.clear();
590
+ dirtyReviews.clear();
548
591
 
549
- return watch(
550
- () =>
551
- subscriptions.map(({ reviewId, mode }, index) => {
552
- if (!dirty.delete(index)) return null;
553
-
554
- try {
555
- return {
556
- value:
557
- reviewId === null
558
- ? catalog(mode)
559
- : {
560
- ...readReview(reviewId),
561
- activity: store.activity.read(reviewId),
562
- coverageRevision: data?.coverageRevision ?? 0,
563
- },
564
- };
565
- } catch (error) {
566
- return {
567
- error:
568
- error instanceof ReviewInputError
569
- ? error.message
570
- : "Could not read review.",
571
- };
572
- }
573
- }),
574
- (notify) => {
575
- const stopRefresh = store.watchWorktrees();
576
-
577
- const stops = [
578
- stopRefresh,
579
- data?.subscribeCoverage(() => {
580
- subscriptions.forEach((item, index) => {
581
- if (item.reviewId !== null) dirty.add(index);
582
- });
583
-
584
- // Coverage never changes the catalog; don't send an all-null line.
585
- if (dirty.size > 0) notify();
586
- }) ?? (() => {}),
587
- store.subscribe((result) => {
588
- if (mark(result.reviewId)) notify();
589
- }),
590
- store.activity.subscribe((id) => {
591
- if (mark(id)) notify();
592
- }),
593
- store.activity.subscribeWorking(() => {
594
- if (mark(null)) notify();
595
- }),
596
- shared?.subscribe(() => {
597
- if (mark(null)) notify();
598
- }) ?? (() => {}),
599
- store.subscribeCatalog(() => {
600
- if (mark(null)) notify();
601
- }),
602
- ];
592
+ return pending;
593
+ };
603
594
 
604
- return () => stops.forEach((stop) => stop());
605
- },
606
- // A missing review is an {error} entry here, never a 404.
607
- () => {},
608
- );
609
- }
595
+ const markReview = (reviewId: string) => {
596
+ if (!reviewIds.has(reviewId)) return false;
597
+ dirtyReviews.add(reviewId);
610
598
 
611
- await ensureScratchpad();
599
+ return true;
600
+ };
612
601
 
613
- return watch(
614
- () => catalog(coverageModeSchema.parse(context.req.query("mode"))),
615
- (notify) => {
616
- const local = store.subscribeCatalog(notify);
617
- const activity = store.activity.subscribeWorking(notify);
618
- const imported = shared?.subscribe(notify);
619
-
620
- return () => {
621
- local();
622
- activity();
623
- imported?.();
624
- };
625
- },
626
- );
602
+ const markLists = () => {
603
+ modes.forEach((mode) => dirtyModes.add(mode));
604
+
605
+ return modes.size > 0;
606
+ };
607
+
608
+ return watch(lines, (notify) => {
609
+ const stops = [
610
+ store.watchWorktrees(),
611
+ data?.subscribeCoverage(() => {
612
+ reviewIds.forEach((id) => dirtyReviews.add(id));
613
+ notify();
614
+ }) ?? (() => {}),
615
+ store.subscribe((result) => {
616
+ if (markReview(result.reviewId)) notify();
617
+ }),
618
+ store.activity.subscribe((id) => {
619
+ if (markReview(id)) notify();
620
+ }),
621
+ store.activity.subscribeWorking(() => {
622
+ if (markLists()) notify();
623
+ }),
624
+ shared?.subscribe(() => {
625
+ if (markLists()) notify();
626
+ }) ?? (() => {}),
627
+ store.subscribeCatalog(() => {
628
+ if (markLists()) notify();
629
+ }),
630
+ ];
631
+
632
+ return () => stops.forEach((stop) => stop());
633
+ });
627
634
  });
628
635
 
629
636
  /** Show a review in Desktop and start preparing its pinned checkouts. */
@@ -684,41 +691,6 @@ export function createReviewApi(
684
691
 
685
692
  return context.json({ ok: true, ...(await openReview(readReview(id))) });
686
693
  });
687
- app.get("/:id/watch", (context) => {
688
- const id = context.req.param("id");
689
-
690
- // Activity changes every renewal; reload the document only when it changed.
691
- let document: Snapshot | undefined;
692
-
693
- return watch(
694
- () => ({
695
- ...(document ??= readReview(id)),
696
- activity: isShared(id)
697
- ? { workingCount: 0, expiresAt: null }
698
- : store.activity.read(id),
699
- }),
700
- (notify) => {
701
- const stopRefresh = store.watchWorktrees();
702
-
703
- const stopDocument = store.subscribe((result) => {
704
- if (result.reviewId === id) {
705
- document = undefined;
706
- notify();
707
- }
708
- });
709
-
710
- const stopActivity = store.activity.subscribe((changed) => {
711
- if (changed === id) notify();
712
- });
713
-
714
- return () => {
715
- stopRefresh();
716
- stopDocument();
717
- stopActivity();
718
- };
719
- },
720
- );
721
- });
722
694
 
723
695
  if (data) {
724
696
  const traceQuery = readQuerySchemas.maps.extend({
@@ -768,12 +740,6 @@ export function createReviewApi(
768
740
  return context.json(result);
769
741
  });
770
742
  app.post("/:id/navigator", reviewAccess("edit"), async (context) => {
771
- if (remoteCaller(context))
772
- throw new ReviewInputError(
773
- "Source windows are not available for a review on another machine.",
774
- 409,
775
- );
776
-
777
743
  const input = readQuerySchemas.file
778
744
  .extend({
779
745
  side: z.enum(["base", "head"]).default("head"),
@@ -782,16 +748,24 @@ export function createReviewApi(
782
748
  })
783
749
  .parse(context.req.query());
784
750
 
785
- return context.json(
786
- await data.navigatorWorkspace(
787
- readReview(context.req.param("id"), input.version),
788
- {
789
- ...input,
790
- empty: input.empty === "true",
791
- anchor: queryAnchor(input),
792
- },
793
- ),
751
+ const answer = await data.navigatorWorkspace(
752
+ readReview(context.req.param("id"), input.version),
753
+ {
754
+ ...input,
755
+ empty: input.empty === "true",
756
+ anchor: queryAnchor(input),
757
+ },
794
758
  );
759
+
760
+ if (context.req.header(REVIEW_CLIENT_HEADER) !== REVIEW_CLIENT_REMOTE)
761
+ return context.json(answer);
762
+
763
+ return context.json({
764
+ ...(input.empty === "true"
765
+ ? { workspacePath: answer.workspacePath, emptySide: true }
766
+ : answer),
767
+ serverId: store.serverId(),
768
+ } satisfies ReviewRemoteNavigatorAnswer);
795
769
  });
796
770
  app.get("/:id/tree", async (context) => {
797
771
  const input = readQuerySchemas.tree.parse(context.req.query());
@@ -884,21 +858,17 @@ export function createReviewApi(
884
858
  queryAnchor(input),
885
859
  );
886
860
 
887
- return context.json(
888
- remoteCaller(context)
889
- ? {
890
- // A live checkout's identity names its path; keep only its equality.
891
- identity: createHash("sha256")
892
- .update(environment.identity)
893
- .digest("hex"),
894
- // An acquisition error can quote local paths.
895
- ...(environment.issue && {
896
- issue:
897
- "The checkout for language features is not available on the remote machine.",
898
- }),
899
- }
900
- : environment,
901
- );
861
+ if (context.req.header(REVIEW_CLIENT_HEADER) !== REVIEW_CLIENT_REMOTE)
862
+ return context.json(environment);
863
+
864
+ // The laptop opens a remote root on this server, so it needs the server id.
865
+ const { rootPath, ...rest } = environment;
866
+
867
+ return context.json({
868
+ ...rest,
869
+ remoteRootPath: rootPath ?? null,
870
+ serverId: store.serverId(),
871
+ } satisfies ReviewRemoteLanguageEnvironment);
902
872
  });
903
873
  app.post("/:id/environment", reviewAccess("active"), async (context) => {
904
874
  const input = z
@@ -972,7 +942,6 @@ export function createReviewApi(
972
942
  }
973
943
 
974
944
  const local =
975
- !remoteCaller(context) &&
976
945
  !input.commit &&
977
946
  !anchor &&
978
947
  input.side === "head" &&
@@ -1013,7 +982,7 @@ export function createReviewApi(
1013
982
  } catch (error) {
1014
983
  send({
1015
984
  type: "error",
1016
- message: error instanceof Error ? error.message : String(error),
985
+ message: errorMessage(error),
1017
986
  });
1018
987
  } finally {
1019
988
  if (!abort.signal.aborted) controller.close();
@@ -1131,7 +1100,9 @@ export function createReviewApi(
1131
1100
  );
1132
1101
 
1133
1102
  return [
1134
- `Selected ${target.kind === "text" ? "text" : "code"} from Whiteboard: ${snapshot.title}`,
1103
+ target.kind === "review"
1104
+ ? `Whiteboard: ${snapshot.title}`
1105
+ : `Selected ${target.kind === "text" ? "text" : "code"} from Whiteboard: ${snapshot.title}`,
1135
1106
  `Session ID: ${snapshot.reviewId}`,
1136
1107
  `Version: ${snapshot.version}`,
1137
1108
  ...(selection.apiSource?.commit
@@ -1171,20 +1142,80 @@ export function createReviewApi(
1171
1142
  return thread;
1172
1143
  };
1173
1144
 
1145
+ /** Where a thread in a review saves itself: its Ask history, recorded
1146
+ * once the agent has a session to reopen. */
1147
+ const historyHooks = (
1148
+ record: Omit<AskRecord, "sessionId">,
1149
+ ): AskThreadHooks => ({
1150
+ onSession: (sessionId) =>
1151
+ store.askHistory.get(record.id)
1152
+ ? store.askHistory.updateSession(record.id, sessionId)
1153
+ : store.askHistory.save({ ...record, sessionId }),
1154
+ onTurn: () => store.askHistory.touch(record.id),
1155
+ onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
1156
+ onPermissions: (permissions) =>
1157
+ store.askHistory.setPermissions(record.id, permissions),
1158
+ onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
1159
+ onTitle: (title) => store.askHistory.rename(record.id, title),
1160
+ });
1161
+
1162
+ /** Threads started for a review before its canvas opened, for it to
1163
+ * show once it does. */
1164
+ const handedOff = new Map<string, string[]>();
1165
+
1166
+ const handOff = (reviewId: string, threadId: string) =>
1167
+ handedOff.set(reviewId, [...(handedOff.get(reviewId) ?? []), threadId]);
1168
+
1169
+ // The conversations started for this review since its canvas last asked,
1170
+ // to open in its Ask panel.
1171
+ app.post("/:id/ask/handoff", (context) => {
1172
+ const reviewId = context.req.param("id");
1173
+ const ids = handedOff.get(reviewId) ?? [];
1174
+
1175
+ handedOff.delete(reviewId);
1176
+
1177
+ return context.json({
1178
+ threads: ids.flatMap((id) => {
1179
+ const record = store.askHistory.get(id);
1180
+
1181
+ if (record)
1182
+ return [{ id, agent: record.agent, selection: record.selection }];
1183
+
1184
+ // Its agent has no session to save yet, but the thread is live.
1185
+ const live = ask.threads.get(id);
1186
+
1187
+ if (live?.reviewId !== reviewId) return [];
1188
+ const state = live.read();
1189
+
1190
+ return [
1191
+ {
1192
+ id,
1193
+ agent: state.agent,
1194
+ selection: {
1195
+ target: { kind: "review" as const },
1196
+ title: state.selection.title,
1197
+ },
1198
+ },
1199
+ ];
1200
+ }),
1201
+ });
1202
+ });
1203
+
1174
1204
  /** What an agent reads before a session's first question: the review,
1175
1205
  * the checkout, and the selection. */
1176
1206
  const askContext = async (
1207
+ threadId: string,
1177
1208
  agent: AskAgentId,
1178
1209
  snapshot: Snapshot,
1179
1210
  checkout: { head: string; live: boolean },
1180
1211
  selection: AgentSelection,
1181
1212
  version: number | undefined,
1182
1213
  ) => {
1183
- const reach = await ask.threads.reach(agent);
1214
+ const reach = await ask.threads.reach(agent, threadId);
1184
1215
  const { reviewId } = snapshot;
1185
1216
 
1186
1217
  return [
1187
- `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about a selection.`,
1218
+ `A reviewer is reading "${snapshot.title}" in Whiteboard and has a question about ${selection.target.kind === "review" ? "it" : "a selection"}.`,
1188
1219
  checkout.live
1189
1220
  ? "Your working directory is the repository the review describes."
1190
1221
  : `Your working directory is a checkout of the review's head commit, ${checkout.head}. Answer from this code, not from other branches.`,
@@ -1204,6 +1235,13 @@ export function createReviewApi(
1204
1235
  ].join("\n");
1205
1236
  };
1206
1237
 
1238
+ registerOnboardingRoutes(app, store, ask, {
1239
+ register: (root) => data.register(root),
1240
+ historyHooks,
1241
+ handOff,
1242
+ open: openCreated,
1243
+ });
1244
+
1207
1245
  // The agents answering in any review, which quitting the app would stop.
1208
1246
  app.get("/ask/working", (context) =>
1209
1247
  context.json({ agents: ask.threads.working() }),
@@ -1291,7 +1329,11 @@ export function createReviewApi(
1291
1329
  const id = crypto.randomUUID();
1292
1330
  const createdAt = new Date().toISOString();
1293
1331
 
1332
+ const permissions =
1333
+ input.permissions ?? defaultAskPermissions(input.agent);
1334
+
1294
1335
  const initialContext = await askContext(
1336
+ id,
1295
1337
  input.agent,
1296
1338
  snapshot,
1297
1339
  checkout,
@@ -1307,30 +1349,21 @@ export function createReviewApi(
1307
1349
  agent: input.agent,
1308
1350
  // Saved once the agent has a session to reopen; a later one
1309
1351
  // replaces a session the agent could not reopen.
1310
- onSession: (sessionId) =>
1311
- store.askHistory.get(id)
1312
- ? store.askHistory.updateSession(id, sessionId)
1313
- : store.askHistory.save({
1314
- id,
1315
- reviewId,
1316
- agent: input.agent,
1317
- sessionId,
1318
- version: snapshot.version,
1319
- head: checkout.head,
1320
- cwd: checkout.rootPath,
1321
- selection: input.selection,
1322
- title: input.question.text.slice(0, 200),
1323
- createdAt,
1324
- updatedAt: createdAt,
1325
- bypass: input.bypass,
1326
- }),
1327
- onTurn: () => store.askHistory.touch(id),
1328
- onSave: (entries) => store.askHistory.saveEntries(id, entries),
1352
+ ...historyHooks({
1353
+ id,
1354
+ reviewId,
1355
+ agent: input.agent,
1356
+ version: snapshot.version,
1357
+ head: checkout.head,
1358
+ cwd: checkout.rootPath,
1359
+ selection: input.selection,
1360
+ title: input.question.text.slice(0, 200),
1361
+ createdAt,
1362
+ updatedAt: createdAt,
1363
+ permissions,
1364
+ }),
1329
1365
  picks: input.picks,
1330
- bypass: input.bypass,
1331
- onBypass: (bypass) => store.askHistory.setBypass(id, bypass),
1332
- onOffer: (offer) => store.askHistory.saveOffer(input.agent, offer),
1333
- onTitle: (title) => store.askHistory.rename(id, title),
1366
+ permissions,
1334
1367
  cwd: checkout.rootPath,
1335
1368
  head: checkout.head,
1336
1369
  selection: {
@@ -1391,7 +1424,7 @@ export function createReviewApi(
1391
1424
  agentName: askAgents[record.agent].name,
1392
1425
  status: "idle",
1393
1426
  readOnly: true,
1394
- bypass: false,
1427
+ permissions: record.permissions ?? "read-only",
1395
1428
  head: record.head,
1396
1429
  cwd: record.cwd,
1397
1430
  title: record.title,
@@ -1428,6 +1461,7 @@ export function createReviewApi(
1428
1461
  const target = record.selection.target;
1429
1462
 
1430
1463
  const initialContext = await askContext(
1464
+ record.id,
1431
1465
  record.agent,
1432
1466
  snapshot,
1433
1467
  checkout,
@@ -1449,15 +1483,9 @@ export function createReviewApi(
1449
1483
  resume: { sessionId: record.sessionId, entries: record.entries },
1450
1484
  // For a new session, should the agent no longer have this one.
1451
1485
  context: initialContext,
1452
- onSession: (sessionId) =>
1453
- store.askHistory.updateSession(record.id, sessionId),
1454
- onTurn: () => store.askHistory.touch(record.id),
1455
- onSave: (entries) => store.askHistory.saveEntries(record.id, entries),
1486
+ ...historyHooks(record),
1456
1487
  picks,
1457
- bypass: record.bypass,
1458
- onBypass: (bypass) => store.askHistory.setBypass(record.id, bypass),
1459
- onOffer: (offer) => store.askHistory.saveOffer(record.agent, offer),
1460
- onTitle: (title) => store.askHistory.rename(record.id, title),
1488
+ permissions: record.permissions,
1461
1489
  });
1462
1490
 
1463
1491
  return context.json({ threadId: record.id });
@@ -1575,7 +1603,7 @@ export function createReviewApi(
1575
1603
  },
1576
1604
  );
1577
1605
 
1578
- // Bypasses permissions from the next answer, or stops.
1606
+ // Changes what the agent may do from the next answer.
1579
1607
  app.post(
1580
1608
  "/:id/ask/:threadId/permissions",
1581
1609
  reviewAccess("edit"),
@@ -1585,7 +1613,7 @@ export function createReviewApi(
1585
1613
  context.req.param("threadId"),
1586
1614
  );
1587
1615
 
1588
- const { bypass } = askPermitSchema.parse(
1616
+ const { permissions } = askPermitSchema.parse(
1589
1617
  await readBoundedRequestJson(context.req.raw),
1590
1618
  );
1591
1619
 
@@ -1596,7 +1624,7 @@ export function createReviewApi(
1596
1624
  );
1597
1625
 
1598
1626
  try {
1599
- await thread.permit(bypass);
1627
+ await thread.permit(permissions);
1600
1628
  } catch (error) {
1601
1629
  throw new ReviewInputError(errorMessage(error), 409);
1602
1630
  }
@@ -1670,7 +1698,13 @@ export function createReviewApi(
1670
1698
  const snapshot = readReview(id, query.version);
1671
1699
 
1672
1700
  if (isShared(id) || !snapshot.pins) return context.json({ layers: [] });
1673
- const layers = await resolveReviewStackLayers(snapshot, store.list());
1701
+
1702
+ const layers = await resolveReviewStackLayers(snapshot, store.list(), {
1703
+ repositoryPath: store
1704
+ .repositories()
1705
+ .find((repository) => repository.id === snapshot.pins!.repositoryId)
1706
+ ?.path,
1707
+ });
1674
1708
 
1675
1709
  return context.json({ layers });
1676
1710
  });
@@ -1962,35 +1996,23 @@ async function locateRepositories(
1962
1996
  };
1963
1997
  }
1964
1998
 
1965
- /** Send committed state, coalescing updates when the reader falls behind. */
1966
- function watch<T>(
1967
- read: () => T,
1999
+ function watch(
2000
+ lines: () => string[],
1968
2001
  subscribe: (notify: () => void) => () => void,
1969
- probe: () => void = read,
1970
2002
  ) {
1971
- probe(); // Return a normal 404 before opening the response.
1972
2003
  let stop = () => {};
1973
2004
 
1974
- let dirty = true;
1975
2005
  const encoder = new TextEncoder();
1976
2006
 
1977
2007
  const send = (controller: ReadableStreamDefaultController<Uint8Array>) => {
1978
- if (
1979
- !dirty ||
1980
- controller.desiredSize === null ||
1981
- controller.desiredSize <= 0
1982
- )
1983
- return;
2008
+ if (controller.desiredSize === null || controller.desiredSize <= 0) return;
1984
2009
 
1985
2010
  try {
1986
- const line = JSON.stringify(read()) + "\n";
2011
+ const pending = lines();
1987
2012
 
1988
- // enqueue can pull synchronously; clear first so it doesn't resend.
1989
- dirty = false;
1990
- controller.enqueue(encoder.encode(line));
2013
+ if (pending.length)
2014
+ controller.enqueue(encoder.encode(pending.join("\n") + "\n"));
1991
2015
  } catch (error) {
1992
- // A review can be deleted while this stream is open. Do not throw into
1993
- // the already-committed writer; close this reader and unsubscribe it.
1994
2016
  stop();
1995
2017
  controller.error(error);
1996
2018
  }
@@ -1998,9 +2020,15 @@ function watch<T>(
1998
2020
 
1999
2021
  const body = new ReadableStream<Uint8Array>({
2000
2022
  start(controller) {
2023
+ let scheduled = false;
2024
+
2001
2025
  stop = subscribe(() => {
2002
- dirty = true;
2003
- send(controller);
2026
+ if (scheduled) return;
2027
+ scheduled = true;
2028
+ queueMicrotask(() => {
2029
+ scheduled = false;
2030
+ send(controller);
2031
+ });
2004
2032
  });
2005
2033
  send(controller);
2006
2034
  },