@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
package/src/ask/agents.ts CHANGED
@@ -14,7 +14,11 @@ import {
14
14
  ndJsonStream,
15
15
  } from "@agentclientprotocol/sdk";
16
16
  import { piTakesMcp, piWithMcp } from "@review/ask/pi-mcp.js";
17
- import { type AskAgentId, askAgentIds } from "@review/ask/thread-state.js";
17
+ import {
18
+ type AskAgentId,
19
+ type AskPermissions,
20
+ askAgentIds,
21
+ } from "@review/ask/thread-state.js";
18
22
 
19
23
  /** How Whiteboard runs an agent that speaks ACP on stdio. */
20
24
  type AskAgentLaunch =
@@ -48,10 +52,13 @@ interface AskAgentSpec {
48
52
  /** How its model gets the MCP servers a session is given, where its
49
53
  * adapter keeps them; without it, the adapter passes them on. */
50
54
  mcp?: AskAgentMcp;
55
+ /** How it decides for itself what is safe to run, in place of its
56
+ * read-only mode and settings: the default where it has one. */
57
+ auto?: AskAgentModeSettings;
51
58
  /** How it edits and runs commands without asking, in place of its
52
59
  * read-only mode and settings, when the reviewer bypasses permissions.
53
60
  * Whiteboard also allows whatever it still asks. */
54
- bypass?: AskAgentBypass;
61
+ bypass?: AskAgentModeSettings;
55
62
  /** A follow-up asked while it answers joins that turn, when it says it
56
63
  * takes one. Only for an adapter that leaves a follow-up arriving as the
57
64
  * turn ends to Whiteboard: Codex's starts a turn of its own instead,
@@ -61,7 +68,7 @@ interface AskAgentSpec {
61
68
 
62
69
  interface AskAgentMcp {
63
70
  /** Whether this install can take them. */
64
- supported(executable: string): Promise<boolean>;
71
+ supported(executable: string, env: NodeJS.ProcessEnv): Promise<boolean>;
65
72
  /** What the adapter runs in place of the user's CLI to give them, with
66
73
  * what it needs in `env`. */
67
74
  launch(
@@ -71,12 +78,37 @@ interface AskAgentMcp {
71
78
  ): Promise<string>;
72
79
  }
73
80
 
74
- export interface AskAgentBypass {
81
+ export interface AskAgentModeSettings {
75
82
  mode?: string;
76
83
  sessionMeta?: NewSessionRequest["_meta"];
77
84
  env?: Record<string, string>;
78
85
  }
79
86
 
87
+ /** The mode, session settings and process settings an agent runs with. */
88
+ export function askAgentSettings(
89
+ agent: AskAgentId,
90
+ permissions: AskPermissions,
91
+ ): AskAgentModeSettings {
92
+ const spec = askAgents[agent];
93
+
94
+ const own =
95
+ permissions === "read-only"
96
+ ? { mode: spec.readOnlyMode }
97
+ : spec[permissions];
98
+
99
+ return {
100
+ mode: own?.mode,
101
+ sessionMeta: own?.sessionMeta ?? spec.sessionMeta,
102
+ env: own?.env ?? spec.env,
103
+ };
104
+ }
105
+
106
+ /** What a new conversation may do: decide for itself where the agent can,
107
+ * else read only. */
108
+ export function defaultAskPermissions(agent: AskAgentId): AskPermissions {
109
+ return askAgents[agent].auto ? "auto" : "read-only";
110
+ }
111
+
80
112
  export const askAgents: Record<AskAgentId, AskAgentSpec> = {
81
113
  claude: {
82
114
  name: "Claude Code",
@@ -107,6 +139,9 @@ export const askAgents: Record<AskAgentId, AskAgentSpec> = {
107
139
  },
108
140
  },
109
141
  },
142
+ // Claude's classifier allows what it judges safe and asks about the rest;
143
+ // its file tools stay off, as read-only's are.
144
+ auto: { mode: "auto" },
110
145
  // Its file tools back, and its own bypass, where its settings allow it.
111
146
  bypass: {
112
147
  mode: "bypassPermissions",
@@ -126,6 +161,8 @@ export const askAgents: Record<AskAgentId, AskAgentSpec> = {
126
161
  },
127
162
  signIn: "codex login",
128
163
  readOnlyMode: "read-only",
164
+ // Codex reviews each action and asks only about ones it finds unsafe.
165
+ auto: { mode: "agent" },
129
166
  bypass: { mode: "agent-full-access" },
130
167
  },
131
168
  cursor: {
@@ -187,6 +224,8 @@ export interface AskAgentStatus {
187
224
  available: boolean;
188
225
  /** Whether it has a mode that keeps the checkout as it is. */
189
226
  readOnly: boolean;
227
+ /** Whether it can decide for itself what is safe to run. */
228
+ auto: boolean;
190
229
  /** Whether Whiteboard can have it edit and run commands without asking. */
191
230
  bypass: boolean;
192
231
  }
@@ -203,9 +242,10 @@ export type AskAgentLauncher = (
203
242
  agent: AskAgentId,
204
243
  cwd: string,
205
244
  options?: {
206
- bypass?: boolean;
245
+ permissions?: AskPermissions;
207
246
  /** The MCP servers its sessions are given. */
208
247
  mcpServers?: McpServer[];
248
+ env?: NodeJS.ProcessEnv;
209
249
  },
210
250
  ) => Promise<AskAgentProcess>;
211
251
 
@@ -256,13 +296,16 @@ async function findAgent(
256
296
  }
257
297
 
258
298
  /** Whether the agent's model gets the MCP servers its sessions are given. */
259
- export async function askAgentTakesMcp(agent: AskAgentId): Promise<boolean> {
299
+ export async function askAgentTakesMcp(
300
+ agent: AskAgentId,
301
+ env: NodeJS.ProcessEnv = process.env,
302
+ ): Promise<boolean> {
260
303
  const spec = askAgents[agent];
261
304
 
262
305
  if (!spec.mcp) return true;
263
- const executable = await findAgent(spec);
306
+ const executable = await findAgent(spec, env);
264
307
 
265
- return executable !== undefined && spec.mcp.supported(executable);
308
+ return executable !== undefined && spec.mcp.supported(executable, env);
266
309
  }
267
310
 
268
311
  export async function detectAskAgents(
@@ -274,6 +317,7 @@ export async function detectAskAgents(
274
317
  name: askAgents[id].name,
275
318
  available: (await findAgent(askAgents[id], env)) !== undefined,
276
319
  readOnly: askAgents[id].readOnlyMode !== undefined,
320
+ auto: askAgents[id].auto !== undefined,
277
321
  bypass: askAgents[id].bypass !== undefined,
278
322
  })),
279
323
  );
@@ -286,16 +330,16 @@ const STDERR_LIMIT = 8_000;
286
330
  export const launchAskAgent: AskAgentLauncher = async (
287
331
  agent,
288
332
  cwd,
289
- { bypass = false, mcpServers = [] } = {},
333
+ { permissions = "read-only", mcpServers = [], env: base = process.env } = {},
290
334
  ) => {
291
335
  const spec = askAgents[agent];
292
- const executable = await findAgent(spec);
336
+ const executable = await findAgent(spec, base);
293
337
 
294
338
  if (!executable) throw new Error(`${spec.name} is not installed.`);
295
339
 
296
340
  const env: NodeJS.ProcessEnv = {
297
- ...process.env,
298
- ...((bypass && spec.bypass?.env) || spec.env),
341
+ ...base,
342
+ ...askAgentSettings(agent, permissions).env,
299
343
  };
300
344
 
301
345
  // A server started from inside a Claude Code session must not look nested.
@@ -309,7 +353,9 @@ export const launchAskAgent: AskAgentLauncher = async (
309
353
 
310
354
  if ("adapter" in launch) {
311
355
  env[launch.executableEnv] =
312
- spec.mcp && mcpServers.length && (await spec.mcp.supported(executable))
356
+ spec.mcp &&
357
+ mcpServers.length &&
358
+ (await spec.mcp.supported(executable, env))
313
359
  ? await spec.mcp.launch(executable, mcpServers, env)
314
360
  : executable;
315
361
 
package/src/ask/pi-mcp.ts CHANGED
@@ -49,13 +49,14 @@ const versions = new Map<
49
49
  { read: number; version: Promise<number[] | undefined> }
50
50
  >();
51
51
 
52
- function piVersion(executable: string) {
52
+ function piVersion(executable: string, env: NodeJS.ProcessEnv) {
53
53
  const cached = versions.get(executable);
54
54
 
55
55
  if (cached && Date.now() - cached.read < VERSION_TTL_MS)
56
56
  return cached.version;
57
57
 
58
58
  const version = promisify(execFile)(executable, ["--version"], {
59
+ env,
59
60
  timeout: 5_000,
60
61
  })
61
62
  .then(({ stdout }) =>
@@ -69,9 +70,12 @@ function piVersion(executable: string) {
69
70
  }
70
71
 
71
72
  /** Whether this Pi can be given MCP servers. */
72
- export async function piTakesMcp(executable: string): Promise<boolean> {
73
+ export async function piTakesMcp(
74
+ executable: string,
75
+ env: NodeJS.ProcessEnv,
76
+ ): Promise<boolean> {
73
77
  if (process.platform === "win32") return false;
74
- const version = await piVersion(executable);
78
+ const version = await piVersion(executable, env);
75
79
 
76
80
  if (!version) return false;
77
81
 
@@ -185,6 +185,12 @@ const statusSchema = z.enum([
185
185
  "failed",
186
186
  ]);
187
187
 
188
+ /** What an agent may do without asking: nothing that changes the checkout,
189
+ * what its own judgment calls safe, or everything. */
190
+ export const askPermissionsSchema = z.enum(["read-only", "auto", "bypass"]);
191
+
192
+ export type AskPermissions = z.infer<typeof askPermissionsSchema>;
193
+
188
194
  /** Everything the Ask panel renders. A watcher gets it whole once, then
189
195
  * follows it through `AskChange`s. */
190
196
  export const askThreadStateSchema = z.object({
@@ -197,8 +203,7 @@ export const askThreadStateSchema = z.object({
197
203
  signIn: z.string().optional(),
198
204
  /** The mode that keeps the agent from changing files was accepted. */
199
205
  readOnly: z.boolean(),
200
- /** The agent edits and runs commands without asking. */
201
- bypass: z.boolean(),
206
+ permissions: askPermissionsSchema,
202
207
  head: z.string(),
203
208
  /** The checkout the agent works in. */
204
209
  cwd: z.string(),
@@ -228,7 +233,7 @@ export const askChangeSchema = z.discriminatedUnion("type", [
228
233
  type: z.literal("set"),
229
234
  status: statusSchema.optional(),
230
235
  readOnly: z.boolean().optional(),
231
- bypass: z.boolean().optional(),
236
+ permissions: askPermissionsSchema.optional(),
232
237
  choices: askChoicesSchema.optional(),
233
238
  commands: z.array(askCommandSchema).optional(),
234
239
  accepts: askAcceptsSchema.optional(),
@@ -290,7 +295,7 @@ export function applyAskChange(
290
295
  ...rest,
291
296
  status: change.status ?? state.status,
292
297
  readOnly: change.readOnly ?? state.readOnly,
293
- bypass: change.bypass ?? state.bypass,
298
+ permissions: change.permissions ?? state.permissions,
294
299
  };
295
300
 
296
301
  const choices = change.choices ?? state.choices;
package/src/ask/thread.ts CHANGED
@@ -22,7 +22,9 @@ import { errorMessage } from "@dev.fast/trace-core";
22
22
  import {
23
23
  type AskAgentLauncher,
24
24
  type AskAgentProcess,
25
+ askAgentSettings,
25
26
  askAgents,
27
+ defaultAskPermissions,
26
28
  } from "@review/ask/agents.js";
27
29
  import {
28
30
  attachmentsOf,
@@ -44,6 +46,7 @@ import {
44
46
  type AskChoices,
45
47
  type AskEntry,
46
48
  type AskOffer,
49
+ type AskPermissions,
47
50
  type AskPicks,
48
51
  type AskQuestion,
49
52
  type AskThreadState,
@@ -92,21 +95,14 @@ const READ_ONLY_MCP_SERVERS = new Set([
92
95
  "fff",
93
96
  ]);
94
97
 
95
- /** MCP servers Whiteboard gives every Ask session. Their tool calls are
96
- * Whiteboard's own, so they run without asking the reviewer. */
97
- export type AskMcpServers = () => McpServer[];
98
+ /** MCP servers Whiteboard gives every Ask session, for the thread they
99
+ * serve. Their tool calls are Whiteboard's own, so they run without asking
100
+ * the reviewer. */
101
+ export type AskMcpServers = (threadId: string) => McpServer[];
98
102
 
99
- interface AskThreadBase {
100
- /** Stable across reopening; a new conversation gets a fresh one. */
101
- id?: string;
102
- reviewId: string;
103
- agent: AskAgentId;
104
- cwd: string;
105
- head: string;
106
- selection: { title: string; quote?: string };
107
- /** What the agent reads before a session's first question: the selection
108
- * and how to find the review. */
109
- context: string;
103
+ /** Where a thread saves what happens to it, which moves with the thread
104
+ * when another review adopts it. */
105
+ export interface AskThreadHooks {
110
106
  /** The agent created a session: the id a later reopen loads. A new one
111
107
  * replaces a session the agent could not reopen. */
112
108
  onSession?: (sessionId: string) => void;
@@ -115,12 +111,8 @@ interface AskThreadBase {
115
111
  /** What the panel shows, after each turn and on close, so a reopen can
116
112
  * show it without waiting for the agent to replay it. */
117
113
  onSave?: (entries: AskEntry[]) => void;
118
- /** The model and effort to answer with, when the agent offers them. */
119
- picks?: AskPicks;
120
- /** Edit and run commands without asking. */
121
- bypass?: boolean;
122
- /** The reviewer started or stopped bypassing permissions. */
123
- onBypass?: (bypass: boolean) => void;
114
+ /** The reviewer changed what the agent may do. */
115
+ onPermissions?: (permissions: AskPermissions) => void;
124
116
  /** The agent said what it offers: choices, commands, or what a question
125
117
  * may carry. */
126
118
  onOffer?: (offer: AskOffer) => void;
@@ -128,6 +120,23 @@ interface AskThreadBase {
128
120
  onTitle?: (title: string) => void;
129
121
  }
130
122
 
123
+ interface AskThreadBase extends AskThreadHooks {
124
+ /** Stable across reopening; a new conversation gets a fresh one. */
125
+ id?: string;
126
+ reviewId: string;
127
+ agent: AskAgentId;
128
+ cwd: string;
129
+ head: string;
130
+ selection: { title: string; quote?: string };
131
+ /** What the agent reads before a session's first question: the selection
132
+ * and how to find the review. */
133
+ context: string;
134
+ /** The model and effort to answer with, when the agent offers them. */
135
+ picks?: AskPicks;
136
+ /** What the agent may do without asking; the agent's default without. */
137
+ permissions?: AskPermissions;
138
+ }
139
+
131
140
  /** A new conversation, or an earlier one to load from the agent. */
132
141
  export type AskThreadStart = AskThreadBase &
133
142
  (
@@ -218,6 +227,9 @@ export class AskThread {
218
227
  private steerable = false;
219
228
  /** Follow-ups waiting for the turn under way to end, oldest first. */
220
229
  private queue: { id: string; question: AskQuestion }[] = [];
230
+ /** The review the thread answers in, and where it saves; adopting moves
231
+ * both. */
232
+ private home: AskThreadHooks & { reviewId: string };
221
233
 
222
234
  constructor(
223
235
  private readonly launch: AskAgentLauncher,
@@ -226,15 +238,24 @@ export class AskThread {
226
238
  private readonly limits: AskThreadLimits = askThreadLimits,
227
239
  ) {
228
240
  this.id = start.id ?? randomUUID();
229
- this.mcpServers = mcpServers();
241
+ this.mcpServers = mcpServers(this.id);
230
242
  this.picks = { ...start.picks };
243
+ this.home = {
244
+ reviewId: start.reviewId,
245
+ onSession: start.onSession,
246
+ onTurn: start.onTurn,
247
+ onSave: start.onSave,
248
+ onPermissions: start.onPermissions,
249
+ onOffer: start.onOffer,
250
+ onTitle: start.onTitle,
251
+ };
231
252
  this.state = {
232
253
  id: this.id,
233
254
  agent: start.agent,
234
255
  agentName: askAgents[start.agent].name,
235
256
  status: "starting",
236
257
  readOnly: false,
237
- bypass: start.bypass ?? false,
258
+ permissions: start.permissions ?? defaultAskPermissions(start.agent),
238
259
  head: start.head,
239
260
  cwd: start.cwd,
240
261
  selection: start.selection,
@@ -247,7 +268,16 @@ export class AskThread {
247
268
  }
248
269
 
249
270
  get reviewId() {
250
- return this.start.reviewId;
271
+ return this.home.reviewId;
272
+ }
273
+
274
+ /** Moves the thread to another review, as when its agent creates the
275
+ * review it was asked for, and saves it there as it stands. */
276
+ adopt(reviewId: string, hooks: AskThreadHooks) {
277
+ this.home = { reviewId, ...hooks };
278
+
279
+ if (this.sessionId) hooks.onSession?.(this.sessionId);
280
+ hooks.onSave?.(this.state.entries);
251
281
  }
252
282
 
253
283
  read(): AskThreadState {
@@ -468,14 +498,15 @@ export class AskThread {
468
498
  this.closed = true;
469
499
 
470
500
  try {
471
- // Starting and failed conversations must not replace saved history.
501
+ // A conversation still starting may be half replayed, and one that
502
+ // failed has nothing new; neither replaces what was saved.
472
503
  if (
473
504
  save &&
474
505
  this.sessionId &&
475
506
  this.state.status !== "starting" &&
476
507
  this.state.status !== "failed"
477
508
  )
478
- this.start.onSave?.(settled(this.state.entries));
509
+ this.home.onSave?.(settled(this.state.entries));
479
510
  } finally {
480
511
  for (const resolve of this.decisions.values())
481
512
  resolve({ outcome: { outcome: "cancelled" } });
@@ -597,7 +628,7 @@ export class AskThread {
597
628
  const generation = ++this.generation;
598
629
 
599
630
  const launched = await this.launch(this.start.agent, this.start.cwd, {
600
- bypass: this.state.bypass,
631
+ permissions: this.state.permissions,
601
632
  mcpServers: this.mcpServers,
602
633
  });
603
634
 
@@ -659,9 +690,8 @@ export class AskThread {
659
690
  initialized.agentCapabilities?.loadSession === true,
660
691
  );
661
692
 
662
- const { bypass } = this.state;
663
- const spec = askAgents[this.start.agent];
664
- const mode = bypass ? spec.bypass?.mode : spec.readOnlyMode;
693
+ const { permissions } = this.state;
694
+ const { mode } = askAgentSettings(this.start.agent, permissions);
665
695
 
666
696
  // An agent without a read-only mode still answers, as it is.
667
697
  const configurable =
@@ -703,7 +733,8 @@ export class AskThread {
703
733
 
704
734
  this.emit({
705
735
  type: "set",
706
- readOnly: !bypass && Boolean(configurable || selectable),
736
+ readOnly:
737
+ permissions === "read-only" && Boolean(configurable || selectable),
707
738
  });
708
739
  this.useConfig(config);
709
740
 
@@ -820,23 +851,30 @@ export class AskThread {
820
851
  if (commands) offer.commands = commands;
821
852
 
822
853
  if (accepts) offer.accepts = accepts;
823
- this.start.onOffer?.(offer);
854
+ this.home.onOffer?.(offer);
824
855
  }
825
856
 
826
- /** Edits and runs commands without asking from the next answer, or stops:
827
- * the agent starts again in that mode and reopens the conversation. */
828
- async permit(bypass: boolean) {
857
+ /** Changes what the agent may do from the next answer: it starts again
858
+ * in that mode and reopens the conversation. */
859
+ async permit(permissions: AskPermissions) {
829
860
  if (this.state.status !== "idle")
830
861
  throw new Error("Settings can change between answers.");
831
862
 
832
- if (bypass === this.state.bypass) return;
863
+ if (permissions === this.state.permissions) return;
833
864
 
834
- if (bypass && !askAgents[this.start.agent].bypass)
835
- throw new Error(`${this.state.agentName} cannot bypass permissions.`);
865
+ if (
866
+ permissions !== "read-only" &&
867
+ !askAgents[this.start.agent][permissions]
868
+ )
869
+ throw new Error(
870
+ permissions === "auto"
871
+ ? `${this.state.agentName} has no auto mode.`
872
+ : `${this.state.agentName} cannot bypass permissions.`,
873
+ );
836
874
 
837
- this.emit({ type: "set", bypass });
875
+ this.emit({ type: "set", permissions });
838
876
  this.permitted = true;
839
- this.start.onBypass?.(bypass);
877
+ this.home.onPermissions?.(permissions);
840
878
  this.disconnect();
841
879
  // Its mode is Whiteboard's change, not one to announce as the agent's.
842
880
  this.settings.clear();
@@ -847,18 +885,18 @@ export class AskThread {
847
885
  });
848
886
  }
849
887
 
850
- /** What the session starts with: read-only, or bypassing permissions. */
888
+ /** What the session starts with, for what the agent may do. */
851
889
  private sessionMeta() {
852
- const spec = askAgents[this.start.agent];
853
-
854
- return (this.state.bypass && spec.bypass?.sessionMeta) || spec.sessionMeta;
890
+ return askAgentSettings(this.start.agent, this.state.permissions)
891
+ .sessionMeta;
855
892
  }
856
893
 
857
894
  /** The agent says which mode it is in now; only its read-only one keeps
858
895
  * the checkout as it is. */
859
896
  private useMode(mode: string) {
860
897
  const readOnly =
861
- !this.state.bypass && mode === askAgents[this.start.agent].readOnlyMode;
898
+ this.state.permissions === "read-only" &&
899
+ mode === askAgents[this.start.agent].readOnlyMode;
862
900
 
863
901
  if (readOnly !== this.state.readOnly) this.emit({ type: "set", readOnly });
864
902
  }
@@ -885,7 +923,7 @@ export class AskThread {
885
923
  if (this.closed) throw new Error("This conversation has ended.");
886
924
 
887
925
  this.sessionId = response.sessionId;
888
- start.onSession?.(response.sessionId);
926
+ this.home.onSession?.(response.sessionId);
889
927
 
890
928
  this.greeting = piGreetingSchema.safeParse(
891
929
  response._meta,
@@ -954,7 +992,7 @@ export class AskThread {
954
992
  ...(withSelection ? [this.start.context] : []),
955
993
  ...(withPermission
956
994
  ? [
957
- this.state.bypass
995
+ this.state.permissions === "bypass"
958
996
  ? "The reviewer lets you change files in the checkout and run commands without asking."
959
997
  : "Do not change files in the checkout.",
960
998
  ]
@@ -1027,8 +1065,8 @@ export class AskThread {
1027
1065
  ? `${this.state.agentName} stopped before finishing.`
1028
1066
  : null,
1029
1067
  });
1030
- this.start.onTurn?.();
1031
- this.start.onSave?.(this.state.entries);
1068
+ this.home.onTurn?.();
1069
+ this.home.onSave?.(this.state.entries);
1032
1070
  this.askQueued();
1033
1071
  }
1034
1072
 
@@ -1050,7 +1088,7 @@ export class AskThread {
1050
1088
  // may save an "always" rule into the checkout's settings.
1051
1089
  if (
1052
1090
  allowOnce &&
1053
- (this.state.bypass ||
1091
+ (this.state.permissions === "bypass" ||
1054
1092
  (server &&
1055
1093
  (READ_ONLY_MCP_SERVERS.has(server) ||
1056
1094
  this.mcpServers.some((provided) => provided.name === server))))
@@ -1302,7 +1340,7 @@ export class AskThread {
1302
1340
 
1303
1341
  if (title && title !== this.state.title) {
1304
1342
  this.emit({ type: "set", title });
1305
- this.start.onTitle?.(title);
1343
+ this.home.onTitle?.(title);
1306
1344
  }
1307
1345
 
1308
1346
  return true;
@@ -1,5 +1,5 @@
1
1
  import { PROTOCOL_VERSION, client, methods } from "@agentclientprotocol/sdk";
2
- import { errorMessage } from "@dev.fast/trace-core";
2
+ import { errorMessage, shellQuote } from "@dev.fast/trace-core";
3
3
  import {
4
4
  type AskAgentLauncher,
5
5
  askAgentTakesMcp,
@@ -139,13 +139,59 @@ async function offeredBy(
139
139
  export interface AskTools {
140
140
  /** Its MCP server, for agents whose model gets it. */
141
141
  mcpServers?: AskMcpServers;
142
- /** A shell command that runs Whiteboard's CLI against this server, for
143
- * the agents that don't. */
144
- cli?: () => string | undefined;
142
+ /** A shell command that runs Whiteboard's CLI against this server for a
143
+ * thread, for the agents that don't. */
144
+ cli?: (threadId: string) => string | undefined;
145
145
  /** Whether an agent's model gets the MCP servers it is given. */
146
146
  takesMcp?: (agent: AskAgentId) => Promise<boolean>;
147
147
  }
148
148
 
149
+ /** Whiteboard's CLI as the tools, with `selector` in its env so it reaches
150
+ * this server and no other. */
151
+ export function cliAskTools(
152
+ cliPath: () => string | undefined,
153
+ selector: { name: string; value: string },
154
+ ): AskTools {
155
+ const env = () => [
156
+ selector,
157
+ ...(process.versions.electron
158
+ ? [{ name: "ELECTRON_RUN_AS_NODE", value: "1" }]
159
+ : []),
160
+ ...(process.env.DEV_REVIEW_HOME
161
+ ? [{ name: "DEV_REVIEW_HOME", value: process.env.DEV_REVIEW_HOME }]
162
+ : []),
163
+ ];
164
+
165
+ return {
166
+ mcpServers: () => {
167
+ const cli = cliPath();
168
+
169
+ return cli
170
+ ? [
171
+ {
172
+ name: "whiteboard",
173
+ command: process.execPath,
174
+ args: [cli, "mcp"],
175
+ env: env(),
176
+ },
177
+ ]
178
+ : [];
179
+ },
180
+ cli: () => {
181
+ const cli = cliPath();
182
+
183
+ return (
184
+ cli &&
185
+ [
186
+ ...env().map(({ name, value }) => `${name}=${shellQuote(value)}`),
187
+ shellQuote(process.execPath),
188
+ shellQuote(cli),
189
+ ].join(" ")
190
+ );
191
+ },
192
+ };
193
+ }
194
+
149
195
  /** How an agent reaches Whiteboard's tools, which its first prompt says. */
150
196
  export type AskToolsReach =
151
197
  | { kind: "mcp" }
@@ -183,13 +229,13 @@ export class AskThreads {
183
229
 
184
230
  /** How the agent's sessions reach Whiteboard's tools: the MCP tools they
185
231
  * are given where its model gets them, else the CLI from its shell. */
186
- async reach(agent: AskAgentId): Promise<AskToolsReach> {
232
+ async reach(agent: AskAgentId, threadId: string): Promise<AskToolsReach> {
187
233
  if (
188
- this.mcpServers().length &&
234
+ this.mcpServers(threadId).length &&
189
235
  (await (this.tools.takesMcp ?? askAgentTakesMcp)(agent))
190
236
  )
191
237
  return { kind: "mcp" };
192
- const command = this.tools.cli?.();
238
+ const command = this.tools.cli?.(threadId);
193
239
 
194
240
  return command ? { kind: "cli", command } : undefined;
195
241
  }
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
8
  import {
9
+ REVIEW_REMOTE_WRAPPER_MARK,
9
10
  type ReviewCliInstallStamp,
10
11
  ReviewCliInstallStampSchema,
11
12
  type ReviewCliInstallStatus,
@@ -58,7 +59,8 @@ function hasManagedShimMarker(source: string): boolean {
58
59
  return (
59
60
  source.includes(SHIM_MARKER) ||
60
61
  source.includes("Managed by Review Desktop") ||
61
- source.includes("Managed by Whiteboard Desktop")
62
+ source.includes("Managed by Whiteboard Desktop") ||
63
+ source.includes(REVIEW_REMOTE_WRAPPER_MARK)
62
64
  );
63
65
  }
64
66