@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
@@ -0,0 +1,280 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { lstat, mkdir, readFile, readdir, rm, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import {
8
+ REVIEW_REMOTE_INSTALL_LOCK,
9
+ REVIEW_REMOTE_LOCK_STALE_SECONDS,
10
+ REVIEW_REMOTE_WRAPPER_MARK,
11
+ } from "@dev.fast/review-protocol";
12
+
13
+ import { whiteboardRemoteHome } from "./remote-extensions";
14
+ import {
15
+ remoteLanguageServerGroups,
16
+ stopProcessGroup,
17
+ } from "./remote-language-server";
18
+ import { DEV_REVIEW_HOME_ENV } from "./review-home-paths";
19
+ import {
20
+ recordedBackgroundServer,
21
+ stopBackgroundServer,
22
+ } from "./server/background-server";
23
+
24
+ const LOCK_STALE_MS = REVIEW_REMOTE_LOCK_STALE_SECONDS * 1000;
25
+
26
+ const REVIEW_STORE = [
27
+ "review-api.db",
28
+ "review-api.db-wal",
29
+ "review-api.db-shm",
30
+ "review-api.db.workspaces",
31
+ "review-api.db.workspaces-wal",
32
+ "review-api.db.workspaces-shm",
33
+ ];
34
+
35
+ export type RemoteUninstallResult =
36
+ | {
37
+ event: "remote.uninstall";
38
+ ok: true;
39
+ removed: string[];
40
+ keptReviews: boolean;
41
+ stoppedServer?: { pid: number; version: string | null };
42
+ }
43
+ | { event: "remote.uninstall"; ok: false; reason: string };
44
+
45
+ export async function remoteUninstall(input: {
46
+ home: string;
47
+ env: NodeJS.ProcessEnv;
48
+ stateDir: string;
49
+ deleteReviews: boolean;
50
+ }): Promise<RemoteUninstallResult> {
51
+ const refuse = (reason: string): RemoteUninstallResult => ({
52
+ event: "remote.uninstall",
53
+ ok: false,
54
+ reason,
55
+ });
56
+
57
+ // A relative home would aim every removal at the working directory.
58
+ if (!path.isAbsolute(input.home))
59
+ return refuse(
60
+ `HOME is ${input.home ? JSON.stringify(input.home) : "not set"}; Whiteboard removes nothing without an absolute home.`,
61
+ );
62
+
63
+ if (!path.isAbsolute(input.stateDir))
64
+ return refuse(
65
+ `The review home ${JSON.stringify(input.stateDir)} is not an absolute path.`,
66
+ );
67
+
68
+ const override = input.env[DEV_REVIEW_HOME_ENV]?.trim();
69
+
70
+ if (
71
+ override &&
72
+ (/[\x00-\x1f\x7f-\x9f]/.test(override) ||
73
+ override.includes("//") ||
74
+ path.resolve(override) !== (override.replace(/\/$/, "") || "/"))
75
+ )
76
+ return refuse(
77
+ `DEV_REVIEW_HOME is ${JSON.stringify(override)}; Whiteboard removes nothing under a review home that is not an absolute, normalised path.`,
78
+ );
79
+
80
+ const install = whiteboardRemoteHome(input.env);
81
+ const wrapper = path.join(input.home, ".local", "bin", "whiteboard");
82
+ const lock = path.join(install, REVIEW_REMOTE_INSTALL_LOCK);
83
+
84
+ const busy = async () =>
85
+ refuse(
86
+ `Desktop on ${(await readText(path.join(lock, "owner"))) || "another computer"} is installing Whiteboard here. Try again when it is done.`,
87
+ );
88
+
89
+ if (existsSync(lock)) {
90
+ if (!(await stale(lock))) return busy();
91
+ await rm(lock, { recursive: true, force: true });
92
+ }
93
+
94
+ const held = existsSync(install);
95
+
96
+ if (
97
+ held &&
98
+ !(await mkdir(lock).then(
99
+ () => true,
100
+ () => false,
101
+ ))
102
+ )
103
+ return busy();
104
+
105
+ try {
106
+ return await removeInstall(input, install, wrapper, refuse);
107
+ } finally {
108
+ if (held) await rm(lock, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ async function removeInstall(
113
+ input: Parameters<typeof remoteUninstall>[0],
114
+ install: string,
115
+ wrapper: string,
116
+ refuse: (reason: string) => RemoteUninstallResult,
117
+ ): Promise<RemoteUninstallResult> {
118
+ const server = await recordedBackgroundServer(input.stateDir);
119
+
120
+ const recorded = server && {
121
+ ...server.discovery,
122
+ version: server.health.version ?? null,
123
+ };
124
+
125
+ const stoppable =
126
+ recorded && recorded.startedBy !== "user" ? recorded.serverPid : undefined;
127
+
128
+ const groups = await remoteLanguageServerGroups(input.env);
129
+ const running = await processesFrom(`${install}/`);
130
+
131
+ if (running === undefined)
132
+ return refuse(
133
+ `Cannot list this host's processes to check that none runs from ${install}.`,
134
+ );
135
+
136
+ const others = running
137
+ .filter(({ pid, pgid }) => pid !== stoppable && !groups.includes(pgid))
138
+ .map(({ pid }) => pid);
139
+
140
+ if (others.length)
141
+ return refuse(
142
+ `${others.map((pid) => (pid === recorded?.serverPid ? `A Whiteboard server you started (process ${pid})` : `Process ${pid}`)).join(", ")} ${others.length > 1 ? "run" : "runs"} from ${install}. Stop ${others.length > 1 ? "them" : "it"}, then run whiteboard remote uninstall again.`,
143
+ );
144
+
145
+ if (recorded?.startedBy === "user" && input.deleteReviews)
146
+ return refuse(
147
+ `A Whiteboard server you started (process ${recorded.serverPid}) uses the reviews in ${input.stateDir}. Stop it, then run whiteboard remote uninstall again.`,
148
+ );
149
+
150
+ await Promise.all(groups.map(stopProcessGroup));
151
+ let stoppedServer: { pid: number; version: string | null } | undefined;
152
+
153
+ if (recorded && stoppable !== undefined) {
154
+ try {
155
+ await stopBackgroundServer({ serverPid: stoppable });
156
+ } catch (error) {
157
+ return refuse(error instanceof Error ? error.message : String(error));
158
+ }
159
+
160
+ stoppedServer = { pid: stoppable, version: recorded.version };
161
+ }
162
+
163
+ const left = await processesFrom(`${install}/`);
164
+
165
+ if (left?.length !== 0)
166
+ return refuse(
167
+ `${left ? `Process ${left.map(({ pid }) => pid).join(", ")}` : "A process"} still runs from ${install}. Stop it, then run whiteboard remote uninstall again.`,
168
+ );
169
+
170
+ const removed: string[] = [];
171
+
172
+ if (existsSync(install)) {
173
+ await rm(install, { recursive: true, force: true });
174
+ removed.push(install);
175
+ }
176
+
177
+ if (await desktopWrote(wrapper)) {
178
+ await rm(wrapper, { force: true });
179
+ removed.push(wrapper);
180
+ }
181
+
182
+ if (input.deleteReviews)
183
+ for (const name of REVIEW_STORE) {
184
+ const file = path.join(input.stateDir, name);
185
+
186
+ if (existsSync(file)) {
187
+ await rm(file, { force: true });
188
+ removed.push(file);
189
+ }
190
+ }
191
+
192
+ const result: RemoteUninstallResult = {
193
+ event: "remote.uninstall",
194
+ ok: true,
195
+ removed,
196
+ keptReviews: !input.deleteReviews,
197
+ };
198
+
199
+ if (stoppedServer) result.stoppedServer = stoppedServer;
200
+
201
+ return result;
202
+ }
203
+
204
+ async function stale(lock: string) {
205
+ const started = await readText(path.join(lock, "started"));
206
+
207
+ if (started !== "")
208
+ return (
209
+ /^\d+$/.test(started) &&
210
+ Date.now() - Number(started) * 1000 >= LOCK_STALE_MS
211
+ );
212
+
213
+ if (existsSync(path.join(lock, "started"))) return false;
214
+
215
+ const since = (await stat(lock).catch(() => undefined))?.mtimeMs;
216
+
217
+ return since !== undefined && Date.now() - since >= LOCK_STALE_MS;
218
+ }
219
+
220
+ const readText = (file: string) =>
221
+ readFile(file, "utf8").then(
222
+ (text) => text.trim(),
223
+ () => "",
224
+ );
225
+
226
+ async function processesFrom(prefix: string) {
227
+ let lines: { pid: number; pgid: number; args: string }[];
228
+
229
+ try {
230
+ const pids = (await readdir("/proc")).filter((name) => /^\d+$/.test(name));
231
+
232
+ lines = await Promise.all(
233
+ pids.map(async (pid) => {
234
+ const [args = "", stat = ""] = await Promise.all(
235
+ [`/proc/${pid}/cmdline`, `/proc/${pid}/stat`].map((file) =>
236
+ readFile(file, "utf8").catch(() => ""),
237
+ ),
238
+ );
239
+
240
+ // The group follows the state and the parent, after the command's parentheses.
241
+ const pgid = Number(
242
+ stat.slice(stat.lastIndexOf(")") + 2).split(" ")[2],
243
+ );
244
+
245
+ return { pid: Number(pid), pgid, args: args.replaceAll("\0", " ") };
246
+ }),
247
+ );
248
+ } catch {
249
+ try {
250
+ const { stdout } = await promisify(execFile)(
251
+ "ps",
252
+ ["-eo", "pid=,pgid=,args="],
253
+ { maxBuffer: 16 << 20 },
254
+ );
255
+
256
+ lines = stdout.split("\n").map((line) => {
257
+ const [, pid = "", pgid = "", args = ""] =
258
+ /^\s*(\d+)\s+(\d+)\s(.*)$/.exec(line) ?? [];
259
+
260
+ return { pid: Number(pid), pgid: Number(pgid), args };
261
+ });
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ return lines.filter(
268
+ ({ pid, args }) => pid && pid !== process.pid && args.includes(prefix),
269
+ );
270
+ }
271
+
272
+ async function desktopWrote(file: string) {
273
+ const entry = await lstat(file).catch(() => undefined);
274
+
275
+ if (!entry?.isFile()) return false;
276
+
277
+ return (await readFile(file, "utf8"))
278
+ .split("\n")
279
+ .includes(REVIEW_REMOTE_WRAPPER_MARK);
280
+ }
@@ -46,8 +46,7 @@ All paths below are relative to `/reviews-api`.
46
46
  | `GET /capabilities` | Desktop availability and permission for optional software-map generation, independent of opening a review |
47
47
  | `GET /:id/activity` | Currently reported authoring work, not stored in document history |
48
48
  | `POST /:id/activity/{begin,update,end} {activityId?,focus?}` | Begin a presence (returns its `activityId`), update its focus and expiry, or end it; each returns the review's live presences |
49
- | `GET /watch` | NDJSON review summaries: initial list, then saved changes |
50
- | `GET /watch?subscriptions=…` | One NDJSON connection for multiple `{reviewId}` subscriptions; `reviewId:null` selects the catalog. Each line is an ordered array of `{value}` or `{error}` results, with `null` where a subscription is unchanged since the previous line. |
49
+ | `GET /watch?subscriptions=[{reviewId,mode?}]` | NDJSON live updates. `reviewId:null` subscribes to the review list in `mode` (`structural` when absent); a string subscribes to that review. Each line names what it is about: `{kind:"list",mode,reviews}`, `{kind:"review",reviewId,value}` (the snapshot plus `activity` and `coverageRevision`), or `{kind:"review",reviewId,error}`. On connect there is one line per list mode and per review, however often each is subscribed; afterwards a line only for what changed (an edit changes the list entry's `version`, so it sends both; activity and coverage send only the review; an unchanged list is not resent). A missing or deleted review is an `error` line and the stream stays open; a reader that falls behind receives each item's latest state once. |
51
50
  | `GET /:id` | Compact outline |
52
51
  | `GET /:id?targetId=step-3` | Full block, sequence step, flow node or flow edge |
53
52
  | `GET /:id?full=true` | Full snapshot |
@@ -55,7 +54,6 @@ All paths below are relative to `/reviews-api`.
55
54
  | `GET /:id/history` | Saved versions with titles and timestamps |
56
55
  | `GET /:id/inspect` | Agent reading view: nested text outline with IDs; `targetId` reads one component completely, `full=true` includes all content, `version` selects history. `format=json` returns raw data instead. |
57
56
  | `POST /:id/open` | Open the review in the attached Desktop; report an error when none is attached |
58
- | `GET /:id/watch` | NDJSON snapshots: current state immediately, then committed updates |
59
57
  | `POST /commands` | Apply one command; return review ID, version, and for an edit the target ID, its `type`, and — after an insert or replace — `children`: its first-level children as `{id,type}` (a container's blocks; a diagram's steps, or nodes then edges), so new components are addressable without a read. A `create` with `pullRequestUrl` returns the newest existing review for that PR instead (owner/repository matched case-insensitively) unless `operation.reuseExisting` is `false`: `created:false`, a `note`, its stored `target`, `headMoved`, and `working`/`otherReviewIds` when they apply; its target is never moved. A new review reports `created:true`. Either way the result carries `review`, the review's `GET /` catalog entry (target, origin, repository name and path). An interactive `create` also opens the new review in an attached Desktop unless `operation.open` is `false`, and reports `opened` with the open result or an `openError`; the review is saved either way |
60
58
  | `POST /commands {operation:{type:"lens_edit",reviewId,edit}}` | Write one file lens, credited to `activityId` when given: `insert {title,targets,afterId?}` (host id `lens-N`), `update {targetId,title?,targets?}` or `remove {targetId}`. Returns `{targetId, type:"lens", uncategorized}`, where `uncategorized` lists changed files and ranges no lens covers yet (at most 50 files) |
61
59
  | `GET /:id/lenses` | The current version's file lenses with each one's file count, plus the same `uncategorized` report |
@@ -63,10 +63,10 @@ export async function runReviewAgentCli(input: AgentCliInput): Promise<number> {
63
63
  if (extra.length || (mode === "mcp" && name))
64
64
  throw new Error("Unexpected arguments. Use whiteboard api --help.");
65
65
 
66
- const headers = {
67
- [REVIEW_VIA_HEADER]: mode === "mcp" ? "mcp" : "api",
68
- [REVIEW_AGENT_HEADER]: reviewSessionAgent(env),
69
- };
66
+ const headers = Object.fromEntries([
67
+ [REVIEW_VIA_HEADER, mode === "mcp" ? "mcp" : "api"],
68
+ [REVIEW_AGENT_HEADER, reviewSessionAgent(env)],
69
+ ]);
70
70
 
71
71
  if (mode === "mcp") {
72
72
  const { serveReviewMcp } = await import("./mcp.js");
@@ -43,9 +43,15 @@ export async function connectReviewApi(
43
43
  return (await connectReviewInstance(env, headers)).client;
44
44
  }
45
45
 
46
+ export interface ConnectReviewOptions {
47
+ desktopInstalled?: () => boolean;
48
+ cli?: readonly string[];
49
+ }
50
+
46
51
  export async function connectReviewInstance(
47
52
  env = process.env,
48
53
  headers: Record<string, string> = {},
54
+ options: ConnectReviewOptions = {},
49
55
  ): Promise<ConnectedReview> {
50
56
  const request: ConstructorParameters<typeof ReviewApiClient>[1] = (
51
57
  url,
@@ -77,6 +83,23 @@ export async function connectReviewInstance(
77
83
  const discovery = healthyReviewInstance(selection);
78
84
 
79
85
  if (!discovery) {
86
+ const { ensureServerWithoutDesktop } =
87
+ await import("@review/server/background-server.js");
88
+
89
+ const server = await ensureServerWithoutDesktop({
90
+ selection,
91
+ env,
92
+ ...options,
93
+ });
94
+
95
+ if (server)
96
+ return {
97
+ client: new ReviewApiClient(
98
+ { serverUrl: server.url, token: server.token },
99
+ request,
100
+ ),
101
+ };
102
+
80
103
  const unavailable = reviewInstanceUnavailable(selection);
81
104
  const message = `${unavailable.message} For headless authoring, select a running server with --state-dir or DEV_REVIEW_SERVER_DIR.`;
82
105
 
@@ -5,9 +5,11 @@ import {
5
5
  type AskEntry,
6
6
  type AskHistoryEntry,
7
7
  type AskOffer,
8
+ type AskPermissions,
8
9
  askEntrySchema,
9
10
  askHistoryEntrySchema,
10
11
  askOfferSchema,
12
+ askPermissionsSchema,
11
13
  } from "@review/ask/thread-state.js";
12
14
  import { z } from "zod";
13
15
 
@@ -23,8 +25,8 @@ export const askRecordSchema = askHistoryEntrySchema.extend({
23
25
  cwd: z.string(),
24
26
  /** What the panel showed last; absent until a turn ends or it closes. */
25
27
  entries: z.array(askEntrySchema).optional(),
26
- /** The agent edits and runs commands without asking. */
27
- bypass: z.boolean().optional(),
28
+ /** What the agent may do without asking, when it reopens. */
29
+ permissions: askPermissionsSchema.optional(),
28
30
  });
29
31
 
30
32
  export type AskRecord = z.infer<typeof askRecordSchema>;
@@ -44,6 +46,7 @@ const rowSchema = z
44
46
  updated_at: z.string(),
45
47
  entries: z.string().nullable(),
46
48
  bypass: z.number(),
49
+ permissions: z.string().nullable(),
47
50
  })
48
51
  .transform((row) =>
49
52
  askRecordSchema.parse({
@@ -59,7 +62,9 @@ const rowSchema = z
59
62
  createdAt: row.created_at,
60
63
  updatedAt: row.updated_at,
61
64
  entries: row.entries === null ? undefined : JSON.parse(row.entries),
62
- bypass: row.bypass === 1,
65
+ // Saved before auto mode: bypassing permissions, or read-only.
66
+ permissions:
67
+ row.permissions ?? (row.bypass === 1 ? "bypass" : "read-only"),
63
68
  }),
64
69
  );
65
70
 
@@ -85,20 +90,27 @@ export class AskHistory {
85
90
  updated_at TEXT NOT NULL,
86
91
  entries TEXT,
87
92
  bypass INTEGER NOT NULL DEFAULT 0,
93
+ permissions TEXT,
88
94
  UNIQUE(agent, session_id));
89
95
  CREATE INDEX IF NOT EXISTS ask_conversations_review ON ask_conversations(review_id, updated_at);`);
90
96
 
91
- // Conversations saved before bypassing permissions lack the column.
92
- if (
93
- !db
97
+ // Conversations saved before bypassing permissions, or before auto mode,
98
+ // lack its column.
99
+ const columns = new Set(
100
+ db
94
101
  .prepare("PRAGMA table_info(ask_conversations)")
95
102
  .all()
96
- .some((column) => String(column.name) === "bypass")
97
- )
103
+ .map((column) => String(column.name)),
104
+ );
105
+
106
+ if (!columns.has("bypass"))
98
107
  db.exec(
99
108
  "ALTER TABLE ask_conversations ADD COLUMN bypass INTEGER NOT NULL DEFAULT 0",
100
109
  );
101
110
 
111
+ if (!columns.has("permissions"))
112
+ db.exec("ALTER TABLE ask_conversations ADD COLUMN permissions TEXT");
113
+
102
114
  // What each agent offered last, so a new question can pick a model and
103
115
  // effort, and a command, before its agent starts.
104
116
  db.exec(
@@ -132,8 +144,8 @@ export class AskHistory {
132
144
  this.assertWritable(record.reviewId);
133
145
  this.db
134
146
  .prepare(
135
- `INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass)
136
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
147
+ `INSERT OR REPLACE INTO ask_conversations(id,review_id,agent,session_id,version,head,cwd,selection,title,created_at,updated_at,bypass,permissions)
148
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
137
149
  )
138
150
  .run(
139
151
  record.id,
@@ -147,16 +159,19 @@ export class AskHistory {
147
159
  record.title,
148
160
  record.createdAt,
149
161
  record.updatedAt,
150
- record.bypass ? 1 : 0,
162
+ record.permissions === "bypass" ? 1 : 0,
163
+ record.permissions ?? null,
151
164
  );
152
165
  }
153
166
 
154
- /** Whether reopening the conversation bypasses permissions. */
155
- setBypass(id: string, bypass: boolean) {
167
+ /** What the agent may do without asking when the conversation reopens. */
168
+ setPermissions(id: string, permissions: AskPermissions) {
156
169
  this.assertThreadWritable(id);
157
170
  this.db
158
- .prepare("UPDATE ask_conversations SET bypass=? WHERE id=?")
159
- .run(bypass ? 1 : 0, id);
171
+ .prepare(
172
+ "UPDATE ask_conversations SET bypass=?, permissions=? WHERE id=?",
173
+ )
174
+ .run(permissions === "bypass" ? 1 : 0, permissions, id);
160
175
  }
161
176
 
162
177
  /** Points a conversation at a new session, when its agent could not
@@ -246,7 +261,7 @@ export class AskHistory {
246
261
  version: _version,
247
262
  cwd: _cwd,
248
263
  entries,
249
- bypass: _bypass,
264
+ permissions: _permissions,
250
265
  ...entry
251
266
  } = rowSchema.parse(row);
252
267
 
@@ -36,7 +36,7 @@ export function authoringTools(
36
36
 
37
37
  const descriptions = {
38
38
  create:
39
- 'Create a review of saved working files, immutable commits or a GitHub PR. Revisions are resolved on acceptance. A worktree target reviews the saved files in its checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base: the branch to compare against, by default the default branch of the repository. The diff starts at the merge base of base and HEAD, which follows rebases. Omitted commits base means source at head with no diff; supply the parent to review introduced changes. For a GitHub PR, pullRequestUrl alone is enough: target and title become optional, and the host fetches the PR into a registered checkout of its repository and pins the current PR head and GitHub diff base, titled from the PR. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting. kind:"scratchpad" names the one scratchpad, which the host creates itself. The result carries review, the review as review_list shows it: its target with resolved commits, origin (its PR), repositoryName and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop.',
39
+ 'Create a review of saved working files, immutable commits or a GitHub PR. Revisions are resolved on acceptance. A worktree target reviews the saved files in its checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base: the branch to compare against, by default the default branch of the repository. The diff starts at the merge base of base and HEAD, which follows rebases. Omitted commits base means source at head with no diff; supply the parent to review introduced changes. For a branch stacked on another, pass the branch below it as base; Desktop links reviews into a stack through their bases. For a GitHub PR, pullRequestUrl alone is enough: target and title become optional, and the host fetches the PR into a registered checkout of its repository and pins the current PR head and GitHub diff base, titled from the PR. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting. kind:"scratchpad" names the one scratchpad, which the host creates itself. The result carries review, the review as review_list shows it: its target with resolved commits, origin (its PR), repositoryName and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop.',
40
40
  set_target:
41
41
  "Move the review to a new target, such as the commits after a rebase or amend, preserving the document and component IDs. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. Omitted pullRequestUrl keeps the PR within the same repository; changing repositories clears it. Supply a URL to replace it or null to detach.",
42
42
  edit: [
@@ -14,7 +14,6 @@ import { type SectionBlock, section, sectionSchema } from "./section.js";
14
14
  import { sequence } from "./sequence.js";
15
15
  import { software_map } from "./software_map.js";
16
16
  import { trace_quote } from "./trace_quote.js";
17
- import { type TutorialBlock, tutorial, tutorialSchema } from "./tutorial.js";
18
17
 
19
18
  /** Leaf kinds share one discriminated union so unknown types read as they always have. */
20
19
  export const leafSchema = z.discriminatedUnion("type", [
@@ -33,7 +32,7 @@ export const leafSchema = z.discriminatedUnion("type", [
33
32
 
34
33
  export type LeafBlock = z.infer<typeof leafSchema>;
35
34
 
36
- export type Block = LeafBlock | SectionBlock | CalloutBlock | TutorialBlock;
35
+ export type Block = LeafBlock | SectionBlock | CalloutBlock;
37
36
 
38
37
  export type BlockType = Block["type"];
39
38
 
@@ -56,7 +55,6 @@ export const blocks = {
56
55
  flow_diagram,
57
56
  section,
58
57
  callout,
59
- tutorial,
60
58
  } satisfies Definitions;
61
59
 
62
60
  /** Discriminated by type, so a malformed block reports its own kind's
@@ -68,7 +66,7 @@ export const blockSchema: z.ZodType<Block> = z.lazy(() =>
68
66
  /** Each block kind's schema, for unions that add kinds of their own. Read
69
67
  * lazily: the container modules import this one. */
70
68
  export const blockKindSchemas = () =>
71
- [leafSchema, sectionSchema, calloutSchema, tutorialSchema] as const;
69
+ [leafSchema, sectionSchema, calloutSchema] as const;
72
70
 
73
71
  function checkBlock<K extends BlockType>(
74
72
  type: K,
@@ -450,10 +450,7 @@ export const editSchema = edits(contentSchema, blockSchema, z.json());
450
450
 
451
451
  export type Edit = z.infer<typeof editSchema>;
452
452
 
453
- // Tutorial blocks are the built-in tutorial's, not for agents to write.
454
- const writableBlockTypes = Object.keys(blocks).filter(
455
- (type) => type !== "tutorial",
456
- );
453
+ const writableBlockTypes = Object.keys(blocks);
457
454
 
458
455
  const namedByType = (types: string[]) =>
459
456
  z