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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/{account-alias-CprZojXd.js → account-alias-CMyXCuuq.js} +13 -8
  2. package/dist/{agent-cli-CThN7Q59.js → agent-cli-CahKfDT0.js} +15 -10
  3. package/dist/agent-cli-CvYV2R6c.js +2 -0
  4. package/dist/{agent-client-BOOi7EUR.js → agent-client-D2RmE9NL.js} +13 -3
  5. package/dist/{authoring-tools-BNwuMnz6.js → authoring-tools-BLhYKrNN.js} +34 -27
  6. package/dist/{background-server-DLSDogkI.js → background-server-CFUU4VtN.js} +1 -1
  7. package/dist/{background-server-CkvxA-6O.js → background-server-k4K_Hvh1.js} +5 -5
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-R4yfa5DT.js → cli-BUVSMGud.js} +1 -1
  10. package/dist/{cli-runner-Cuc9FSzu.js → cli-runner-C7W6VA94.js} +26 -26
  11. package/dist/cli.js +4 -3
  12. package/dist/{client-BN_FDxFr.js → client-B0f4EJ7v.js} +253 -47
  13. package/dist/{fuzzy-match-BO9bK37N.js → fuzzy-match-BbaX-dGh.js} +6 -4
  14. package/dist/{headless-host-Cog6SpS9.js → headless-host-C7xYMe74.js} +513 -140
  15. package/dist/{headless-host-CVBOQyRS.js → headless-host-cYdlRYPB.js} +1 -1
  16. package/dist/lens-selection-CAtsGaym.js +214 -0
  17. package/dist/{local-data-D_9tvdsp.js → local-data-DrNSaiS5.js} +2416 -320
  18. package/dist/{mcp-D1hziANa.js → mcp-yXnjUkOV.js} +6 -10
  19. package/dist/{package-paths-C--__jEv.js → package-paths-BWxO-UEL.js} +1 -1
  20. package/dist/{profile-DokFw782.js → profile-BlqZlCPZ.js} +20 -8
  21. package/dist/profile-CGxNfeYQ.js +2 -0
  22. package/dist/{remote-attach-CEJ-kvDY.js → remote-attach-CZTrtWIk.js} +4 -4
  23. package/dist/{remote-extensions-Blcjr9Ze.js → remote-extensions-CNXwqK0D.js} +3 -3
  24. package/dist/{remote-extensions-GN8Gf_qG.js → remote-extensions-VP-kfq0C.js} +1 -1
  25. package/dist/{remote-language-server-BTKBQ7x4.js → remote-language-server-BiPzol99.js} +5 -5
  26. package/dist/{remote-uninstall-DW7Gdryh.js → remote-uninstall-CFTkoioA.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin--8lLCclv.js} +55 -14
  28. package/dist/{review-app-launcher-CkwLclzR.js → review-app-launcher-KA2_mOeo.js} +2 -2
  29. package/dist/{review-home-paths-j2BRAaNv.js → review-home-paths-D8AoCdd2.js} +19 -17
  30. package/dist/{review-telemetry-BLNwYnLV.js → review-telemetry-B2JniTIs.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-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 +29 -76
  38. package/dist/{server-discovery-Bey8IqXH.js → server-discovery-CRXG48Uz.js} +3 -3
  39. package/dist/sharing/index.d.ts +484 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-CyYCvVHx.js} +90 -17
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-B_BijGHF.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-D6MlnGv7.js} +7 -2
  45. package/dist/{tool-failure-C9MpbXJJ.js → tool-failure-_makh_Sr.js} +10 -3
  46. package/instructions/authoring.md +18 -5
  47. package/package.json +6 -6
  48. package/src/ask/agents.ts +13 -1
  49. package/src/ask/checkout-files.ts +2 -5
  50. package/src/ask/thread-state.ts +4 -0
  51. package/src/ask/thread.ts +16 -0
  52. package/src/ask/threads.ts +24 -3
  53. package/src/cli-install.ts +13 -2
  54. package/src/cli-runner.ts +4 -1
  55. package/src/cli.ts +4 -1
  56. package/src/diff-selection-migration.ts +48 -34
  57. package/src/heading-anchors.ts +42 -0
  58. package/src/lens-selection.ts +38 -2
  59. package/src/review-api/README.md +52 -13
  60. package/src/review-api/activity.ts +1 -4
  61. package/src/review-api/agent-cli.ts +9 -1
  62. package/src/review-api/anchor-quotes.ts +14 -7
  63. package/src/review-api/ask-history.ts +137 -51
  64. package/src/review-api/authoring-tools.ts +45 -30
  65. package/src/review-api/blocks/definition.ts +1 -1
  66. package/src/review-api/blocks/markdown.ts +2 -2
  67. package/src/review-api/board-order.ts +144 -0
  68. package/src/review-api/checkout-groups.ts +560 -0
  69. package/src/review-api/document-headings.ts +58 -27
  70. package/src/review-api/document-text.ts +31 -2
  71. package/src/review-api/document.ts +281 -34
  72. package/src/review-api/foreign-references.ts +328 -0
  73. package/src/review-api/github-provider.ts +51 -13
  74. package/src/review-api/http.ts +702 -123
  75. package/src/review-api/instructions.ts +0 -19
  76. package/src/review-api/link-preview.ts +113 -0
  77. package/src/review-api/local-data.ts +310 -46
  78. package/src/review-api/mcp.ts +4 -12
  79. package/src/review-api/onboarding.ts +1 -1
  80. package/src/review-api/profile.ts +37 -6
  81. package/src/review-api/public-tools.ts +24 -2
  82. package/src/review-api/pull-request.ts +82 -49
  83. package/src/review-api/request-origin.ts +10 -0
  84. package/src/review-api/review-progress.ts +61 -27
  85. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  86. package/src/review-api/store-migrations.ts +357 -0
  87. package/src/review-api/store-schema.ts +38 -29
  88. package/src/review-api/store.ts +2144 -480
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +44 -14
  111. package/dist/agent-cli-C6SPSII0.js +0 -2
  112. package/dist/profile-CYPmvBoA.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -1,4 +1,4 @@
1
- import { A as ReviewSelectedDiffSchema, C as ReviewApiSelectionSourceSchema } from "./src-DsM3jO8G.js";
1
+ import { M as ReviewSelectedDiffSchema, T as ReviewApiSelectionSourceSchema } from "./src-CyYCvVHx.js";
2
2
  import { z } from "zod";
3
3
  /** A semantic selection, independent of comment/thread creation. */
4
4
  const AgentSelectionSchema = z.strictObject({
@@ -374,7 +374,12 @@ const askHistoryEntrySchema = z.object({
374
374
  /** The commit the agent read. */
375
375
  head: z.string(),
376
376
  createdAt: z.string(),
377
- updatedAt: z.string()
377
+ updatedAt: z.string(),
378
+ /** The board the conversation started on, and the block if it was about one. */
379
+ anchor: z.object({
380
+ board: z.string(),
381
+ block: z.string().optional()
382
+ }).optional()
378
383
  });
379
384
  //#endregion
380
385
  export { askHistoryEntrySchema as a, askPicksSchema as c, selectionMarkdown as d, askEntrySchema as i, askQuestionSchema as l, askAgentIds as n, askOfferSchema as o, askChoiceKinds as r, askPermissionsSchema as s, applyAskChange as t, AgentSelectionSchema as u };
@@ -1,5 +1,6 @@
1
1
  import { m as isStringValue, n as isJsonObject } from "./src-X9phtB2j.js";
2
- import { n as callAuthoringTool, s as ReviewApiError, t as ToolText } from "./agent-client-BOOi7EUR.js";
2
+ import { l as boardTitle } from "./src-CyYCvVHx.js";
3
+ import { n as callAuthoringTool, s as ReviewApiError, t as ToolText } from "./agent-client-D2RmE9NL.js";
3
4
  import { t as ReviewInputError } from "./input-error-OLgB_h31.js";
4
5
  //#region src/review-api/public-tools.ts
5
6
  function publicDescription(text) {
@@ -38,7 +39,7 @@ function publicResult(value) {
38
39
  if (Array.isArray(value)) return value.map(publicResult);
39
40
  if (!isJsonObject(value)) return value;
40
41
  return Object.fromEntries(Object.entries(value).map(([key, item]) => {
41
- return [key === "reviewId" ? "sessionId" : key === "review" ? "session" : key === "reviews" ? "sessions" : key, key === "review" || key === "reviews" ? publicResult(item) : item];
42
+ return [key === "reviewId" ? "sessionId" : key === "review" ? "session" : key === "reviews" ? "sessions" : key, key === "review" || key === "reviews" ? publicResult(item) : key === "title" && "reviewId" in value && isStringValue(item) ? boardTitle(item) : item];
42
43
  }));
43
44
  }
44
45
  async function callPublicTool(client, tool, input, signal) {
@@ -47,7 +48,13 @@ async function callPublicTool(client, tool, input, signal) {
47
48
  const fields = { ...rest };
48
49
  if (sessionId !== void 0) fields.reviewId = sessionId;
49
50
  const result = await callAuthoringTool(client, tool, fields, signal);
50
- return result instanceof ToolText ? result : publicResult(result);
51
+ return result instanceof ToolText ? result : withoutRepositoryIds(publicResult(result));
52
+ }
53
+ /** Agents never see repository ids. */
54
+ function withoutRepositoryIds(value) {
55
+ if (Array.isArray(value)) return value.map(withoutRepositoryIds);
56
+ if (!isJsonObject(value)) return value;
57
+ return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "repositoryId").map(([key, item]) => [key, withoutRepositoryIds(item)]));
51
58
  }
52
59
  //#endregion
53
60
  //#region src/review-api/recovery.ts
@@ -1,12 +1,24 @@
1
1
  you are writing an interactive rfc-style whiteboard, for consumption by a staff engineer.
2
2
 
3
+ **plans and stacks**
4
+
5
+ Organize related work automatically; do not wait for the user to request grouping, child boards, or dependency order. Choose the board structure before the authoring flow below. Use `session_list` to find an existing plan or stack overview in this project before creating one.
6
+
7
+ - For a plan with no changes yet, create a board with `target:{kind:"project", repositoryPath}`. Do not use a worktree or commit target for a plan: those attach code and show the code-board icon.
8
+ - When implementing a plan in several changes, keep the plan as the top-level board. Create one child board per PR or change with `parentId` set to the plan's session ID. Keep the plan's project target; do not replace it with one child's diff.
9
+ - When reviewing an existing PR stack without a plan, create one project-target board for the overview, then one child per PR. Explain the whole change on the overview and each layer's own diff on its child.
10
+ - Set each child's `dependsOn` to the session IDs it builds on. For a linear stack, each child after the first depends on the previous child. `parentId` groups boards; `dependsOn` orders them. Git bases alone do not create these relationships.
11
+ - For commit targets, use the branch below each layer as its `base`. For PR targets, use the PR itself; its GitHub base selects that layer's diff.
12
+ - Nest only one level: plan or overview → change boards. Update existing boards after rebases instead of creating duplicates. If `session_create` returns an existing PR board, use `session_set_parent` and `session_set_dependencies` to set its relationships.
13
+ - Link the children from the overview and link the parent from each child with `[label](whiteboard:board/<sessionId>)`. Read the overview and all children back to check their targets, parent, dependency order, and links.
14
+
3
15
  **flow**
4
- follow these first five steps in order, with no tool calls beyond what they need: loading the tools they use (`session_create`, `session_activity_begin`, `session_edit`, `session_get`, and later `session_activity_update` and `session_activity_end`) and, for a PR, reading its description (`gh pr view`).
16
+ For each board, follow these first five steps in order, with no tool calls beyond what they need: loading the tools they use (`session_create`, `session_activity_begin`, `session_edit`, `session_get`, and later `session_activity_update` and `session_activity_end`) and, for a PR, reading its description (`gh pr view`).
5
17
 
6
- - create the whiteboard, pinned to the commits/pr the user describes, naming the checkout by its `repositoryPath`. if they name none, use a `worktree` target with no `base`: it reviews the checkout, uncommitted work included, against the default branch. for uncommitted work only, pass `base: "HEAD"`. untracked files are left out: run `git add -N <file>` on new files you want reviewed
7
- - trigger a subagent with this exact instruction: "Call `session_get_instructions({topic:\"file-lenses\"})` and follow it for whiteboard <sessionId>, whose change is `<base>..<head>` in `<repositoryPath>`."
18
+ - create the whiteboard with a `target` naming the checkout by its `repositoryPath`: `{kind:"commits", head, base?}` for commits, `{kind:"pullRequest", pullRequest}` for a PR by URL or number, or `{kind:"project"}` for a plan with no change yet. if they name none, use a `worktree` target with no `base`: it reviews the checkout, uncommitted work included, against the default branch. for uncommitted work only, pass `base: "HEAD"`. untracked files are left out: run `git add -N <file>` on new files you want reviewed
19
+ - for a change board only, trigger a subagent with this exact instruction: "Call `session_get_instructions({topic:\"file-lenses\"})` and follow it for whiteboard <sessionId>, whose change is `<base>..<head>` in `<repositoryPath>`."
8
20
  - call `session_activity_begin` and pass its `activityId` on every `session_edit`
9
- - read the diff with git (or jj) in `repositoryPath`, at the commits `session_create` returned: `git diff <base> <head>`, or for a worktree target `git diff <base>` plus `git status` for untracked files. if it shows no changes, the target is wrong: fix it with `session_set_target` before writing
21
+ - for a project-target plan or overview, read the relevant source or the stack's combined diff; no diff is expected on the project target itself. For a change board, read the diff with git (or jj) in `repositoryPath`, at the commits `session_create` returned: `git diff <base> <head>`, or for a worktree target `git diff <base>` plus `git status` for untracked files. if it shows no changes, the target is wrong: fix it with `session_set_target` before writing
10
22
  - immediately after reading the diff (and the PR description) - put down a first pass at the what/why section.
11
23
 
12
24
  - whiteboard structure - each of these should be written as a top-level `section`, in this order:
@@ -37,4 +49,5 @@ follow these first five steps in order, with no tool calls beyond what they need
37
49
  - use `session_activity_update` to provide regular status updates on your area of focus.
38
50
  - keep whiteboards short and sweet when possible (esp. for small changes.) feel free to omit sections.
39
51
  - when something (a phrase in the prose, diagram node, etc.) describes actual code in the codebase, always default to attaching/hyperlink code.
40
- - Link repository code as `[label](review-source:head/src/file.ts#L10-L24)`; use `base` for old code. Use repository-relative paths and verified line numbers.
52
+ - Link repository code as `[label](whiteboard:source/head/src/file.ts#L10-L24)`; use `base` for old code. To quote any other commit of the board's repository, as a plan does, put the commit in place of `head`: `[label](whiteboard:source/<commit>/src/file.ts#L10-L24)`. Use repository-relative paths and verified line numbers.
53
+ - Link another whiteboard inline as `[label](whiteboard:board/<sessionId>)`, or one of its blocks as `[label](whiteboard:board/<sessionId>#<blockId>)`, wherever the prose refers to it. The board and block must exist.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.fast/whiteboard",
3
- "version": "0.3.4",
3
+ "version": "0.3.5-preview.20261020.130",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,7 +48,7 @@
48
48
  "@parcel/watcher": "2.6.0",
49
49
  "commander": "14.0.3",
50
50
  "fuzzysort": "4.0.2",
51
- "hono": "4.13.7",
51
+ "hono": "4.13.11",
52
52
  "mdast-util-from-markdown": "2.0.3",
53
53
  "mdast-util-gfm": "3.1.0",
54
54
  "mdast-util-math": "3.0.0",
@@ -74,11 +74,11 @@
74
74
  "tsx": "4.21.0",
75
75
  "typescript": "6.0.3",
76
76
  "vitest": "4.1.11",
77
- "@dev.fast/json": "0.0.0",
78
77
  "@dev.fast/review-share-protocol": "0.1.2",
79
78
  "@dev.fast/trace-core": "0.0.0",
80
- "@dev.fast/trace-protocol": "0.5.0",
81
- "@dev.fast/review-protocol": "0.0.0"
79
+ "@dev.fast/json": "0.0.0",
80
+ "@dev.fast/review-protocol": "0.0.0",
81
+ "@dev.fast/trace-protocol": "0.5.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=24 <25"
@@ -88,7 +88,7 @@
88
88
  "repo": "devdotfast/review",
89
89
  "docs": "packages/review/instructions/authoring.md"
90
90
  },
91
- "gitHead": "f33bd6620742fe65ccc1d5fef416fbe8254dcf79",
91
+ "gitHead": "e4fb75564bd27f464cf214a4b3a9a75ada3d54a3",
92
92
  "scripts": {
93
93
  "prebuild": "pnpm run build:workspace-deps",
94
94
  "build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
package/src/ask/agents.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type AskPermissions,
20
20
  askAgentIds,
21
21
  } from "@review/ask/thread-state.js";
22
+ import { ASK_THREAD_ENV } from "@review/review-api/request-origin.js";
22
23
 
23
24
  /** How Whiteboard runs an agent that speaks ACP on stdio. */
24
25
  type AskAgentLaunch =
@@ -247,6 +248,8 @@ export type AskAgentLauncher = (
247
248
  /** The MCP servers its sessions are given. */
248
249
  mcpServers?: McpServer[];
249
250
  env?: NodeJS.ProcessEnv;
251
+ /** The Ask thread this agent serves. */
252
+ threadId?: string;
250
253
  },
251
254
  ) => Promise<AskAgentProcess>;
252
255
 
@@ -357,7 +360,12 @@ const STDERR_LIMIT = 8_000;
357
360
  export const launchAskAgent: AskAgentLauncher = async (
358
361
  agent,
359
362
  cwd,
360
- { permissions = "read-only", mcpServers = [], env: base = process.env } = {},
363
+ {
364
+ permissions = "read-only",
365
+ mcpServers = [],
366
+ env: base = process.env,
367
+ threadId,
368
+ } = {},
361
369
  ) => {
362
370
  const spec = askAgents[agent];
363
371
  const executable = await findAgent(spec, base);
@@ -369,6 +377,10 @@ export const launchAskAgent: AskAgentLauncher = async (
369
377
  ...askAgentSettings(agent, permissions).env,
370
378
  };
371
379
 
380
+ // Lets `whiteboard api` calls from the agent's shell name the thread.
381
+ // The MCP server gets it in its own env, since MCP clients may not pass theirs on.
382
+ if (threadId) env[ASK_THREAD_ENV] = threadId;
383
+
372
384
  // A server started from inside a Claude Code session must not look nested.
373
385
  delete env.CLAUDECODE;
374
386
  delete env.CLAUDE_CODE_ENTRYPOINT;
@@ -1,13 +1,10 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
-
4
- const execFileAsync = promisify(execFile);
1
+ import { runVcs } from "@dev.fast/local-vcs";
5
2
 
6
3
  /** The checkout's files relative to its root: tracked ones, and new ones git
7
4
  * does not ignore. None where git cannot list them. */
8
5
  export async function checkoutFiles(root: string): Promise<string[]> {
9
6
  try {
10
- const { stdout } = await execFileAsync(
7
+ const { stdout } = await runVcs(
11
8
  "git",
12
9
  [
13
10
  "-C",
@@ -375,6 +375,10 @@ export const askHistoryEntrySchema = z.object({
375
375
  head: z.string(),
376
376
  createdAt: z.string(),
377
377
  updatedAt: z.string(),
378
+ /** The board the conversation started on, and the block if it was about one. */
379
+ anchor: z
380
+ .object({ board: z.string(), block: z.string().optional() })
381
+ .optional(),
378
382
  });
379
383
 
380
384
  export type AskHistoryEntry = z.infer<typeof askHistoryEntrySchema>;
package/src/ask/thread.ts CHANGED
@@ -176,6 +176,7 @@ export class AskThread {
176
176
  /** The number of changes so far; a snapshot carries the one it includes. */
177
177
  private seq = 0;
178
178
  private readonly listeners = new Set<(update: AskUpdate) => void>();
179
+ private readonly statusListeners = new Set<() => void>();
179
180
  private readonly closers = new Set<() => void>();
180
181
  private readonly decisions = new Map<
181
182
  string,
@@ -288,6 +289,10 @@ export class AskThread {
288
289
  return { seq: this.seq, snapshot: this.state };
289
290
  }
290
291
 
292
+ get status() {
293
+ return this.state.status;
294
+ }
295
+
291
296
  /** Every change after the current `seq`, in order. */
292
297
  subscribe(listener: (update: AskUpdate) => void) {
293
298
  this.listeners.add(listener);
@@ -295,6 +300,13 @@ export class AskThread {
295
300
  return () => this.listeners.delete(listener);
296
301
  }
297
302
 
303
+ /** Calls back when the status changes, without following the thread. */
304
+ onStatusChange(listener: () => void) {
305
+ this.statusListeners.add(listener);
306
+
307
+ return () => this.statusListeners.delete(listener);
308
+ }
309
+
298
310
  /** Whether anything follows the thread, as an open panel does. */
299
311
  watched() {
300
312
  return this.listeners.size > 0;
@@ -630,6 +642,7 @@ export class AskThread {
630
642
  const launched = await this.launch(this.start.agent, this.start.cwd, {
631
643
  permissions: this.state.permissions,
632
644
  mcpServers: this.mcpServers,
645
+ threadId: this.id,
633
646
  });
634
647
 
635
648
  // Closed, stopped or given up on while the process started.
@@ -1533,5 +1546,8 @@ export class AskThread {
1533
1546
  const update = { seq: this.seq, change };
1534
1547
 
1535
1548
  for (const listener of this.listeners) listener(update);
1549
+
1550
+ if (change.type === "set" && change.status)
1551
+ for (const listener of this.statusListeners) listener();
1536
1552
  }
1537
1553
  }
@@ -18,6 +18,7 @@ import {
18
18
  type AskThreadStart,
19
19
  askThreadLimits,
20
20
  } from "@review/ask/thread.js";
21
+ import { ASK_THREAD_ENV } from "@review/review-api/request-origin.js";
21
22
 
22
23
  /** How long an agent may take to say what it offers. */
23
24
  const OFFER_TIMEOUT_MS = 30_000;
@@ -163,7 +164,7 @@ export function cliAskTools(
163
164
  ];
164
165
 
165
166
  return {
166
- mcpServers: () => {
167
+ mcpServers: (threadId) => {
167
168
  const cli = cliPath();
168
169
 
169
170
  return cli
@@ -172,7 +173,9 @@ export function cliAskTools(
172
173
  name: "whiteboard",
173
174
  command: process.execPath,
174
175
  args: [cli, "mcp"],
175
- env: env(),
176
+ // MCP clients may not pass their own env on, so the server
177
+ // gets the thread it serves in its own.
178
+ env: [...env(), { name: ASK_THREAD_ENV, value: threadId }],
176
179
  },
177
180
  ]
178
181
  : [];
@@ -218,6 +221,8 @@ export class AskThreads {
218
221
 
219
222
  private readonly mcpServers: AskMcpServers;
220
223
 
224
+ private readonly statusListeners = new Set<() => void>();
225
+
221
226
  constructor(
222
227
  private readonly launch: AskAgentLauncher,
223
228
  private readonly tools: AskTools = {},
@@ -249,6 +254,8 @@ export class AskThreads {
249
254
  );
250
255
 
251
256
  this.threads.set(thread.id, thread);
257
+ thread.onStatusChange(() => this.statusChanged());
258
+ this.statusChanged();
252
259
  void thread.open();
253
260
  this.sweeper ??= setInterval(() => this.sweep(), SWEEP_MS);
254
261
  this.sweeper.unref?.();
@@ -291,6 +298,19 @@ export class AskThreads {
291
298
  }
292
299
  }
293
300
 
301
+ /** Calls back when a thread opens, closes or changes its status. */
302
+ subscribeStatus(listener: () => void) {
303
+ this.statusListeners.add(listener);
304
+
305
+ return () => {
306
+ this.statusListeners.delete(listener);
307
+ };
308
+ }
309
+
310
+ private statusChanged() {
311
+ for (const listener of this.statusListeners) listener();
312
+ }
313
+
294
314
  get(id: string) {
295
315
  return this.threads.get(id);
296
316
  }
@@ -313,9 +333,10 @@ export class AskThreads {
313
333
 
314
334
  close(id: string, save = true) {
315
335
  const thread = this.threads.get(id);
316
- this.threads.delete(id);
317
336
  this.unwatchedSince.delete(id);
318
337
  thread?.close(save);
338
+
339
+ if (this.threads.delete(id)) this.statusChanged();
319
340
  }
320
341
 
321
342
  closeDeletedReviews(isDeleted: (reviewId: string) => boolean) {
@@ -699,11 +699,16 @@ export async function writePathShim(
699
699
  cliPath: string,
700
700
  runtimePath: string | undefined,
701
701
  devHome: string,
702
+ previewHome = devHome,
702
703
  ): Promise<void> {
703
704
  if (process.platform === "win32") {
704
705
  await writeFileAtomicAsync(
705
706
  shimPath,
706
- windowsCliShim(cliPath, runtimePath ?? process.execPath, devHome),
707
+ windowsCliShim(
708
+ cliPath,
709
+ runtimePath ?? process.execPath,
710
+ devHome === previewHome ? devHome : undefined,
711
+ ),
707
712
  { replaceSymlink: true },
708
713
  );
709
714
 
@@ -716,6 +721,8 @@ export async function writePathShim(
716
721
  FALLBACK_CLI=${shSingleQuote(cliPath)}
717
722
  FALLBACK_RUNTIME=${shSingleQuote(runtimePath ?? "")}
718
723
  DEFAULT_HOME=${shSingleQuote(devHome)}
724
+ channel="\${DEV_FAST_REVIEW_CHANNEL:-\${DEV_REVIEW_INSTANCE:-}}"
725
+ [ "$channel" = preview ] && DEFAULT_HOME=${shSingleQuote(previewHome)}
719
726
  export DEV_REVIEW_HOME="\${DEV_REVIEW_HOME:-$DEFAULT_HOME}"
720
727
  DESKTOP="$DEV_REVIEW_HOME/review-desktop"
721
728
 
@@ -836,7 +843,11 @@ export async function installReviewCommand(input: {
836
843
  shimPath,
837
844
  input.cliPath,
838
845
  input.cliRuntimePath,
839
- devReviewHome(env, homeDir),
846
+ devReviewHome({ DEV_REVIEW_HOME: env.DEV_REVIEW_HOME }, homeDir),
847
+ devReviewHome(
848
+ { DEV_REVIEW_HOME: env.DEV_REVIEW_HOME, DEV_REVIEW_INSTANCE: "preview" },
849
+ homeDir,
850
+ ),
840
851
  );
841
852
 
842
853
  if (await traceMachineEnabled({ homeDir, env })) {
package/src/cli-runner.ts CHANGED
@@ -5,6 +5,7 @@ import { createInterface } from "node:readline/promises";
5
5
  import { Writable } from "node:stream";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
+ import { boardTitle } from "@dev.fast/review-protocol";
8
9
  import {
9
10
  REVIEW_CLI_INSTALL_TARGET_LABELS,
10
11
  REVIEW_REMOTE_AGENT_IDS,
@@ -836,7 +837,9 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
836
837
  : "Whiteboard Desktop is ready in the background. Pass --focus to bring it forward.\n",
837
838
  );
838
839
  } else {
839
- input.stdout.write(`Whiteboard Desktop is showing "${event.title}".\n`);
840
+ input.stdout.write(
841
+ `Whiteboard Desktop is showing "${boardTitle(event.title)}".\n`,
842
+ );
840
843
  }
841
844
  };
842
845
 
package/src/cli.ts CHANGED
@@ -84,9 +84,12 @@ async function maybeDelegateToDesktopCli(
84
84
  // This bootstrap runs before the Node floor check, so it cannot import
85
85
  // devReviewHome() from @dev.fast/trace-core: that module graph needs a modern
86
86
  // Node. Keep this copy in step with devReviewHome().
87
+ const channel =
88
+ env.DEV_FAST_REVIEW_CHANNEL?.trim() || env.DEV_REVIEW_INSTANCE?.trim();
89
+
87
90
  const devHome = env.DEV_REVIEW_HOME?.trim()
88
91
  ? path.resolve(env.DEV_REVIEW_HOME.trim())
89
- : path.join(os.homedir(), ".dev");
92
+ : path.join(os.homedir(), channel === "preview" ? ".dev-preview" : ".dev");
90
93
 
91
94
  let cliPath: string;
92
95
  let runtimePath: string | undefined;
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type JsonObject,
2
3
  type JsonValue,
3
4
  isJsonObject,
4
5
  isNumberValue,
@@ -14,50 +15,63 @@ export function migrateDiffSelections(input: unknown): JsonValue {
14
15
  return migrateNode(jsonValueSchema.parse(input));
15
16
  }
16
17
 
18
+ /** Keys whose values held retired attachments. */
19
+ export const ATTACHMENT_KEYS = [
20
+ "source",
21
+ "peek",
22
+ "sources",
23
+ "contextSources",
24
+ "callSite",
25
+ ];
26
+
27
+ /** A retired attachment as a selection, or undefined for anything else. */
28
+ export function retiredSelection(value: JsonObject): JsonObject | undefined {
29
+ if (!isStringValue(value.file)) return undefined;
30
+
31
+ if (isNumberValue(value.fromLine) && isNumberValue(value.toLine)) {
32
+ const side = value.side ?? value.graph ?? "head";
33
+
34
+ return {
35
+ file: value.file,
36
+ start: { side, line: value.fromLine },
37
+ end: { side, line: value.toLine },
38
+ };
39
+ }
40
+
41
+ if (
42
+ isJsonObject(value.start) &&
43
+ isJsonObject(value.end) &&
44
+ "baseLine" in value.start
45
+ ) {
46
+ const endpoint = (row: JsonObject) =>
47
+ isNumberValue(row.headLine)
48
+ ? { side: "head", line: row.headLine }
49
+ : { side: "base", line: row.baseLine ?? null };
50
+
51
+ return {
52
+ file: value.file,
53
+ start: endpoint(value.start),
54
+ end: endpoint(value.end),
55
+ };
56
+ }
57
+
58
+ return undefined;
59
+ }
60
+
17
61
  function migrateNode(value: JsonValue, attachment = false): JsonValue {
18
62
  if (Array.isArray(value))
19
63
  return value.map((child) => migrateNode(child, attachment));
20
64
 
21
65
  if (!isJsonObject(value)) return value;
22
66
 
23
- if (attachment && isStringValue(value.file)) {
24
- if (isNumberValue(value.fromLine) && isNumberValue(value.toLine)) {
25
- const side = value.side ?? value.graph ?? "head";
26
-
27
- return {
28
- file: value.file,
29
- start: { side, line: value.fromLine },
30
- end: { side, line: value.toLine },
31
- };
32
- }
33
-
34
- if (
35
- isJsonObject(value.start) &&
36
- isJsonObject(value.end) &&
37
- "baseLine" in value.start
38
- ) {
39
- const endpoint = (row: typeof value.start) =>
40
- isNumberValue(row.headLine)
41
- ? { side: "head", line: row.headLine }
42
- : { side: "base", line: row.baseLine };
43
-
44
- return {
45
- file: value.file,
46
- start: endpoint(value.start),
47
- end: endpoint(value.end),
48
- };
49
- }
50
- }
67
+ const selection = attachment ? retiredSelection(value) : undefined;
68
+
69
+ if (selection) return selection;
51
70
 
52
71
  return Object.fromEntries(
53
72
  Object.entries(value).map(([key, child]) => [
54
73
  key,
55
- migrateNode(
56
- child,
57
- ["source", "peek", "sources", "contextSources", "callSite"].includes(
58
- key,
59
- ),
60
- ),
74
+ migrateNode(child, ATTACHMENT_KEYS.includes(key)),
61
75
  ]),
62
76
  );
63
77
  }
@@ -0,0 +1,42 @@
1
+ import { slugify, uniqueId } from "./slug.js";
2
+
3
+ // A board's headings, as the server publishes them and the editor shows
4
+ // them: one rule for both, so a heading's anchor is the same in each.
5
+
6
+ /** Whether a Markdown heading with this many `#`s is one of the board's
7
+ * headings, with an anchor and a Contents row: `##` and `###`. */
8
+ export function isBoardHeading(hashes: number | undefined): boolean {
9
+ return hashes === 2 || hashes === 3;
10
+ }
11
+
12
+ /** Hands out heading anchors in document order: `slugify(text)`, made
13
+ * unique, so imported `#fragment` links still land. */
14
+ export function headingAnchors(): (text: string) => string {
15
+ const used = new Set<string>();
16
+
17
+ return (text) => {
18
+ const id = uniqueId(slugify(text) || "section", used);
19
+
20
+ used.add(id);
21
+
22
+ return id;
23
+ };
24
+ }
25
+
26
+ /**
27
+ * A Markdown heading's depth in Contents: one step under its section,
28
+ * whose blocks sit at `base`, and under the block's bigger headings before
29
+ * it. `open` holds those headings by `#` count, and takes this one.
30
+ */
31
+ export function markdownHeadingDepth(
32
+ open: number[],
33
+ hashes: number,
34
+ base: number,
35
+ ): number {
36
+ while (open.length && open.at(-1)! >= hashes) open.pop();
37
+ const depth = base + open.length;
38
+
39
+ open.push(hashes);
40
+
41
+ return depth;
42
+ }
@@ -24,8 +24,23 @@ const sameSideAnchor = /^(head|base)\/(.+)#L(\d+)(?:-L(\d+))?$/;
24
24
 
25
25
  const crossSideAnchor = /^diff\/(.+)#([LR])(\d+)-([LR])(\d+)$/;
26
26
 
27
+ const commitLinkAnchor = /^([0-9a-f]{7,40})\/(.+#L\d+(?:-L\d+)?)$/i;
28
+
29
+ /** A source link's commit form, "<sha>/path#L10-L24" (without its
30
+ * `whiteboard:source/` prefix): lines of that commit of the board's own
31
+ * repository, read as their head side. Undefined for any other form. */
32
+ export function commitLinkTarget(
33
+ target: string,
34
+ ): { commit: string; anchor: Anchor } | undefined {
35
+ const match = commitLinkAnchor.exec(target);
36
+
37
+ return match
38
+ ? { commit: match[1]!.toLowerCase(), anchor: `head/${match[2]!}` }
39
+ : undefined;
40
+ }
41
+
27
42
  /** An anchor's selection, as markdown source links write it too (without
28
- * their `review-source:` scheme). Undefined when it isn't one. */
43
+ * their `whiteboard:source/` prefix). Undefined when it isn't one. */
29
44
  export function parseAnchor(text: string): DiffSelection | undefined {
30
45
  const same = sameSideAnchor.exec(text);
31
46
 
@@ -195,6 +210,26 @@ export function sourceAnchor(source: LensSource): FileLineRange {
195
210
  return sourceAnchors(source)[0]!;
196
211
  }
197
212
 
213
+ /** Why a selection cannot be resolved at its pins, as telemetry reports it. */
214
+ export const SELECTION_UNAVAILABLE_REASONS = [
215
+ "missing_file",
216
+ "no_alignment",
217
+ "outside_lines",
218
+ "other",
219
+ ] as const;
220
+
221
+ export type SelectionUnavailableReason =
222
+ (typeof SELECTION_UNAVAILABLE_REASONS)[number];
223
+
224
+ export class SelectionUnavailableError extends Error {
225
+ constructor(
226
+ readonly reason: SelectionUnavailableReason,
227
+ message: string,
228
+ ) {
229
+ super(message);
230
+ }
231
+ }
232
+
198
233
  /** Resolve once against the renderer's rows. No matching or context expansion. */
199
234
  export function resolveDiffSelection(
200
235
  source: LensSource,
@@ -210,7 +245,8 @@ export function resolveDiffSelection(
210
245
  const end = locate(source.end);
211
246
 
212
247
  if (start < 0 || end < start)
213
- throw new Error(
248
+ throw new SelectionUnavailableError(
249
+ "outside_lines",
214
250
  "Lens endpoints do not identify an ordered interval in this diff.",
215
251
  );
216
252
  const selected = rows.slice(start, end + 1);