@dev.fast/whiteboard 0.0.0-stage → 0.2.1-preview.20261005.98

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 (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -2
  3. package/THIRD_PARTY_NOTICES.md +93 -0
  4. package/dist/account-alias-DOHN1RSH.js +973 -0
  5. package/dist/agent-cli-ChOq0ZuO.js +117 -0
  6. package/dist/agent-cli-DzWfZr6G.js +2 -0
  7. package/dist/agent-client-CPQo7iTI.js +305 -0
  8. package/dist/authoring-tools-Cr6Kpjsx.js +234 -0
  9. package/dist/build-info.json +1 -0
  10. package/dist/cli-hVgHcdsv.js +47 -0
  11. package/dist/cli-runner-D8y2_luU.js +5095 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +128 -0
  14. package/dist/client-DFoh50W3.js +950 -0
  15. package/dist/desktop-discovery-DBO-PM2V.js +164 -0
  16. package/dist/error-message-OtiDonty.js +6 -0
  17. package/dist/fs-utils-BMPLt0cr.js +22 -0
  18. package/dist/fuzzy-match-BoAmcyak.js +30 -0
  19. package/dist/headless-host-BJxf4dUU.js +94 -0
  20. package/dist/input-error-OLgB_h31.js +11 -0
  21. package/dist/local-data-CxLk75rx.js +4491 -0
  22. package/dist/mcp-QeF8vkdf.js +140 -0
  23. package/dist/package-paths-B6-zxvIO.js +47 -0
  24. package/dist/process-error-telemetry-v9e5h6D7.js +3952 -0
  25. package/dist/profile-4ry2f3AM.js +76 -0
  26. package/dist/profile-Bh5FIrmf.js +2 -0
  27. package/dist/request-origin-D_4QIdoN.js +811 -0
  28. package/dist/review-agent-traces-DNJFYzc8.js +3866 -0
  29. package/dist/review-home-paths-6zZH-1c9.js +2560 -0
  30. package/dist/review-telemetry-DEFnQsQ2.js +1195 -0
  31. package/dist/runtime-CKiWhGrf.js +52 -0
  32. package/dist/runtime.d.ts +9 -0
  33. package/dist/runtime.js +2 -0
  34. package/dist/s3-SzLpeCG6.js +345 -0
  35. package/dist/s3-config-BgxkSoOy.js +2 -0
  36. package/dist/s3-config-CAxhO9u_.js +676 -0
  37. package/dist/server/desktop-host.d.ts +4 -0
  38. package/dist/server/desktop-host.js +1702 -0
  39. package/dist/server-discovery-Dbzn3w6Z.js +66 -0
  40. package/dist/sharing/index.d.ts +1977 -0
  41. package/dist/sharing/index.js +2 -0
  42. package/dist/src-CGP5ytbV.js +283 -0
  43. package/dist/src-CmdiBL20.js +3676 -0
  44. package/dist/src-DnwdaQ2r.js +865 -0
  45. package/dist/src-X9phtB2j.js +68 -0
  46. package/dist/stored-document-migration-DHeMpLMb.js +2671 -0
  47. package/dist/tool-failure-C8zB73HV.js +98 -0
  48. package/dist/tutorial-trace-DrBvkaeL.js +43 -0
  49. package/instructions/authoring.md +36 -0
  50. package/instructions/file-lenses.md +13 -0
  51. package/instructions/scratchpad.md +23 -0
  52. package/instructions/trace-archaeology.md +113 -0
  53. package/onboarding.md +8 -0
  54. package/package.json +106 -3
  55. package/src/agent-selection.ts +100 -0
  56. package/src/agent-session-ref.ts +80 -0
  57. package/src/ask/agents.ts +351 -0
  58. package/src/ask/checkout-files.ts +47 -0
  59. package/src/ask/file-refs.ts +111 -0
  60. package/src/ask/pi-mcp.ts +154 -0
  61. package/src/ask/protocol.ts +244 -0
  62. package/src/ask/thread-state.ts +350 -0
  63. package/src/ask/thread.ts +1400 -0
  64. package/src/ask/threads.ts +219 -0
  65. package/src/ask/watch.ts +79 -0
  66. package/src/cli-install.ts +1041 -0
  67. package/src/cli-runner.ts +1461 -0
  68. package/src/cli-runtime-info.ts +35 -0
  69. package/src/cli.ts +222 -0
  70. package/src/connect-prompts.ts +248 -0
  71. package/src/cursor-deeplink.ts +11 -0
  72. package/src/desktop-discovery.ts +355 -0
  73. package/src/diff-selection-migration.ts +63 -0
  74. package/src/embedded-posthog-key.ts +6 -0
  75. package/src/error-telemetry.ts +247 -0
  76. package/src/evidence.ts +14 -0
  77. package/src/exception-telemetry.ts +126 -0
  78. package/src/fixtures/blocks/call_stack_diff.json +27 -0
  79. package/src/fixtures/blocks/callout.json +11 -0
  80. package/src/fixtures/blocks/code.json +9 -0
  81. package/src/fixtures/blocks/code_peek.json +8 -0
  82. package/src/fixtures/blocks/database_lens.json +51 -0
  83. package/src/fixtures/blocks/divider.json +1 -0
  84. package/src/fixtures/blocks/fixtures.ts +33 -0
  85. package/src/fixtures/blocks/flow_diagram.json +36 -0
  86. package/src/fixtures/blocks/ids.ts +8 -0
  87. package/src/fixtures/blocks/image.json +9 -0
  88. package/src/fixtures/blocks/markdown.json +7 -0
  89. package/src/fixtures/blocks/section.json +11 -0
  90. package/src/fixtures/blocks/sequence.json +31 -0
  91. package/src/fixtures/blocks/software_map.json +7 -0
  92. package/src/fixtures/blocks/trace_quote.json +9 -0
  93. package/src/fixtures/blocks/tutorial.json +51 -0
  94. package/src/fs-utils.ts +32 -0
  95. package/src/fuzzy-match.ts +94 -0
  96. package/src/install.ts +29 -0
  97. package/src/legacy-skills.ts +133 -0
  98. package/src/lens-selection.ts +230 -0
  99. package/src/markdown-latex-math.ts +230 -0
  100. package/src/markdown.ts +81 -0
  101. package/src/package-paths.ts +53 -0
  102. package/src/posthog-capture-client.ts +610 -0
  103. package/src/review-api/README.md +295 -0
  104. package/src/review-api/activity.ts +340 -0
  105. package/src/review-api/agent-cli.ts +219 -0
  106. package/src/review-api/agent-client.ts +188 -0
  107. package/src/review-api/anchor-quotes.ts +88 -0
  108. package/src/review-api/ask-history.ts +242 -0
  109. package/src/review-api/authoring-tools.ts +211 -0
  110. package/src/review-api/blocks/call_stack_diff.ts +75 -0
  111. package/src/review-api/blocks/callout.ts +24 -0
  112. package/src/review-api/blocks/code.ts +9 -0
  113. package/src/review-api/blocks/code_peek.ts +13 -0
  114. package/src/review-api/blocks/database_lens.ts +150 -0
  115. package/src/review-api/blocks/definition.ts +40 -0
  116. package/src/review-api/blocks/divider.ts +6 -0
  117. package/src/review-api/blocks/flow_diagram.ts +111 -0
  118. package/src/review-api/blocks/image.ts +10 -0
  119. package/src/review-api/blocks/index.ts +90 -0
  120. package/src/review-api/blocks/markdown.ts +17 -0
  121. package/src/review-api/blocks/section.ts +24 -0
  122. package/src/review-api/blocks/sequence.ts +56 -0
  123. package/src/review-api/blocks/software_map.ts +9 -0
  124. package/src/review-api/blocks/trace_quote.ts +10 -0
  125. package/src/review-api/blocks/tutorial.ts +39 -0
  126. package/src/review-api/checkout-fs.ts +14 -0
  127. package/src/review-api/client.ts +1 -0
  128. package/src/review-api/comparison-coverage.ts +304 -0
  129. package/src/review-api/component-reference.ts +30 -0
  130. package/src/review-api/diff-lenses.ts +175 -0
  131. package/src/review-api/document-headings.ts +51 -0
  132. package/src/review-api/document-text.ts +200 -0
  133. package/src/review-api/document.ts +906 -0
  134. package/src/review-api/file-lenses.ts +100 -0
  135. package/src/review-api/http.ts +1877 -0
  136. package/src/review-api/image-decode.ts +29 -0
  137. package/src/review-api/input-error.ts +9 -0
  138. package/src/review-api/instructions.ts +89 -0
  139. package/src/review-api/lens-alignment.ts +52 -0
  140. package/src/review-api/local-data.ts +1837 -0
  141. package/src/review-api/map-input.ts +154 -0
  142. package/src/review-api/mcp-client-agent.ts +32 -0
  143. package/src/review-api/mcp.ts +240 -0
  144. package/src/review-api/origin.ts +43 -0
  145. package/src/review-api/profile.ts +146 -0
  146. package/src/review-api/public-tools.ts +102 -0
  147. package/src/review-api/pull-request.ts +389 -0
  148. package/src/review-api/read-schemas.ts +85 -0
  149. package/src/review-api/recovery.ts +2 -0
  150. package/src/review-api/request-origin.ts +33 -0
  151. package/src/review-api/review-progress.ts +382 -0
  152. package/src/review-api/status-tool.ts +10 -0
  153. package/src/review-api/store-schema.ts +22 -0
  154. package/src/review-api/store.ts +1647 -0
  155. package/src/review-api/tool-failure.ts +64 -0
  156. package/src/review-api/trace-schema.ts +13 -0
  157. package/src/review-api/traces.ts +119 -0
  158. package/src/review-api/unsupported-files.integration.ts +91 -0
  159. package/src/review-api/workspaces.ts +692 -0
  160. package/src/review-api/worktree-source.ts +170 -0
  161. package/src/review-api/worktree-structural.integration.ts +320 -0
  162. package/src/review-app-launcher.ts +432 -0
  163. package/src/review-app-picker.ts +168 -0
  164. package/src/review-app.ts +134 -0
  165. package/src/review-bundled-tools.ts +249 -0
  166. package/src/review-checkout-paths.ts +37 -0
  167. package/src/review-diff-files.ts +89 -0
  168. package/src/review-head-checkout.ts +300 -0
  169. package/src/review-home-paths.ts +73 -0
  170. package/src/review-info.ts +59 -0
  171. package/src/review-instances.ts +113 -0
  172. package/src/review-logger.ts +183 -0
  173. package/src/review-preferences.ts +88 -0
  174. package/src/review-prepare.ts +291 -0
  175. package/src/review-stack.ts +112 -0
  176. package/src/review-telemetry.ts +1066 -0
  177. package/src/runtime.ts +74 -0
  178. package/src/server/account-alias.ts +30 -0
  179. package/src/server/bounded-stream.ts +34 -0
  180. package/src/server/bug-report.ts +301 -0
  181. package/src/server/client-error-budget.ts +45 -0
  182. package/src/server/crash-report.ts +273 -0
  183. package/src/server/desktop-host-shutdown.ts +64 -0
  184. package/src/server/desktop-host.ts +190 -0
  185. package/src/server/desktop-server.ts +703 -0
  186. package/src/server/diffr-config.ts +509 -0
  187. package/src/server/diffr-languages.ts +97 -0
  188. package/src/server/global-verb-relay.ts +190 -0
  189. package/src/server/headless-host.ts +149 -0
  190. package/src/server/hono-http.ts +162 -0
  191. package/src/server/http-json.ts +24 -0
  192. package/src/server/json-review-reporting.ts +174 -0
  193. package/src/server/process-error-telemetry.ts +174 -0
  194. package/src/server/review-api-parsers.ts +90 -0
  195. package/src/server/review-lifecycle-telemetry.ts +73 -0
  196. package/src/server/review-open-watchdog.ts +46 -0
  197. package/src/server/review-server-core.ts +290 -0
  198. package/src/server/structural-comparisons.ts +153 -0
  199. package/src/server/structural-diff.ts +186 -0
  200. package/src/server/tutorial-service.ts +241 -0
  201. package/src/server/ui-telemetry.ts +198 -0
  202. package/src/server-discovery.ts +95 -0
  203. package/src/session-markers.ts +132 -0
  204. package/src/sharing/auth.ts +41 -0
  205. package/src/sharing/cli.ts +83 -0
  206. package/src/sharing/client.ts +334 -0
  207. package/src/sharing/export.ts +195 -0
  208. package/src/sharing/host.ts +316 -0
  209. package/src/sharing/import.ts +787 -0
  210. package/src/sharing/index.ts +16 -0
  211. package/src/sharing/repository.ts +145 -0
  212. package/src/sharing/routes.ts +34 -0
  213. package/src/slug.ts +23 -0
  214. package/src/software-map-diff-counts.ts +517 -0
  215. package/src/software-map-model.ts +1147 -0
  216. package/src/software-map-topology-diff.ts +260 -0
  217. package/src/source.ts +92 -0
  218. package/src/startup-trace.ts +232 -0
  219. package/src/stored-document-migration.ts +151 -0
  220. package/src/telemetry-clean-text.ts +257 -0
  221. package/src/telemetry-config.ts +298 -0
  222. package/src/telemetry-debug-sink.ts +38 -0
  223. package/src/telemetry.ts +13 -0
  224. package/src/trace-cli.ts +156 -0
  225. package/src/trace-storage-cli.ts +509 -0
  226. package/src/tutorial-conversation.ts +18 -0
  227. package/src/ui-telemetry-events.ts +765 -0
  228. package/src/unified-diff.ts +71 -0
  229. package/src/viewed-coverage.ts +259 -0
  230. package/src/windows-cli.ts +125 -0
  231. package/tutorial/document.json +275 -0
  232. package/tutorial/runtime-manifest.json +12 -0
  233. package/tutorial/sample-service/package.json +9 -0
  234. package/tutorial/sample-service/src/api/checkout-api.ts +17 -0
  235. package/tutorial/sample-service/src/app.ts +26 -0
  236. package/tutorial/sample-service/src/database/schema.ts +10 -0
  237. package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +15 -0
  238. package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +24 -0
  239. package/tutorial/sample-service/src/inventory/inventory-service.ts +11 -0
  240. package/tutorial/sample-service/src/orders/order-service.ts +34 -0
  241. package/tutorial/sample-service/src/orders/order.ts +21 -0
  242. package/tutorial/sample-service/src/orders/orders-repository.ts +24 -0
  243. package/tutorial/sample-service/src/payments/payment-gateway.ts +12 -0
  244. package/tutorial/sample-service/src/shipping/shipping-gateway.ts +15 -0
  245. package/tutorial/sample-service/tsconfig.json +12 -0
  246. package/tutorial/software-map.json +139 -0
  247. package/tutorial/trace.json +20 -0
@@ -0,0 +1,1400 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import {
6
+ type ClientConnection,
7
+ type ContentBlock,
8
+ type McpServer,
9
+ PROTOCOL_VERSION,
10
+ type RequestPermissionRequest,
11
+ type RequestPermissionResponse,
12
+ type SessionConfigOption,
13
+ type SessionNotification,
14
+ type SessionUpdate,
15
+ type StopReason,
16
+ type ToolCallUpdate,
17
+ type ToolKind,
18
+ client,
19
+ methods,
20
+ } from "@agentclientprotocol/sdk";
21
+ import { errorMessage } from "@dev.fast/trace-core";
22
+ import {
23
+ type AskAgentLauncher,
24
+ type AskAgentProcess,
25
+ askAgents,
26
+ } from "@review/ask/agents.js";
27
+ import {
28
+ attachmentsOf,
29
+ choicesOf,
30
+ commandOf,
31
+ mcpServerOf,
32
+ selectOptionsSchema,
33
+ settled,
34
+ signedOut,
35
+ toolDetails,
36
+ toolOutput,
37
+ withContext,
38
+ withoutContext,
39
+ } from "@review/ask/protocol.js";
40
+ import {
41
+ type AskAgentId,
42
+ type AskChange,
43
+ type AskChoiceKind,
44
+ type AskChoices,
45
+ type AskEntry,
46
+ type AskOffer,
47
+ type AskPicks,
48
+ type AskQuestion,
49
+ type AskThreadState,
50
+ type AskUpdate,
51
+ applyAskChange,
52
+ askChoiceKinds,
53
+ } from "@review/ask/thread-state.js";
54
+ import { z } from "zod";
55
+
56
+ /** Refused without asking: changes to files, and leaving the read-only
57
+ * mode (Claude asks to exit plan mode with its plan). */
58
+ const REFUSED_KINDS = new Set<ToolKind>([
59
+ "edit",
60
+ "delete",
61
+ "move",
62
+ "switch_mode",
63
+ ]);
64
+
65
+ type PermissionEntry = Extract<AskEntry, { kind: "permission" }>;
66
+
67
+ type NoticeEntry = Extract<AskEntry, { kind: "notice" }>;
68
+
69
+ type ToolEntry = Extract<AskEntry, { kind: "tool" }>;
70
+
71
+ const piGreetingSchema = z.object({
72
+ piAcp: z.object({ startupInfo: z.string() }),
73
+ });
74
+
75
+ const cursorTodosSchema = z.object({ todos: z.unknown() }).loose();
76
+
77
+ /** MCP servers the reviewer's own agent may bring whose tools only read,
78
+ * so their calls run without asking, like the agent reading files. */
79
+ const READ_ONLY_MCP_SERVERS = new Set([
80
+ // fff: fast file search (find_files, grep, multi_grep).
81
+ "fff",
82
+ ]);
83
+
84
+ /** MCP servers Whiteboard gives every Ask session. Their tool calls are
85
+ * Whiteboard's own, so they run without asking the reviewer. */
86
+ export type AskMcpServers = () => McpServer[];
87
+
88
+ interface AskThreadBase {
89
+ /** Stable across reopening; a new conversation gets a fresh one. */
90
+ id?: string;
91
+ reviewId: string;
92
+ agent: AskAgentId;
93
+ cwd: string;
94
+ head: string;
95
+ selection: { title: string; quote?: string };
96
+ /** What the agent reads before a session's first question: the selection
97
+ * and how to find the review. */
98
+ context: string;
99
+ /** The agent created a session: the id a later reopen loads. A new one
100
+ * replaces a session the agent could not reopen. */
101
+ onSession?: (sessionId: string) => void;
102
+ /** A turn ended. */
103
+ onTurn?: () => void;
104
+ /** What the panel shows, after each turn and on close, so a reopen can
105
+ * show it without waiting for the agent to replay it. */
106
+ onSave?: (entries: AskEntry[]) => void;
107
+ /** The model and effort to answer with, when the agent offers them. */
108
+ picks?: AskPicks;
109
+ /** Edit and run commands without asking. */
110
+ bypass?: boolean;
111
+ /** The reviewer started or stopped bypassing permissions. */
112
+ onBypass?: (bypass: boolean) => void;
113
+ /** The agent said what it offers: choices, commands, or what a question
114
+ * may carry. */
115
+ onOffer?: (offer: AskOffer) => void;
116
+ /** The agent named the conversation. */
117
+ onTitle?: (title: string) => void;
118
+ }
119
+
120
+ /** A new conversation, or an earlier one to load from the agent. */
121
+ export type AskThreadStart = AskThreadBase &
122
+ (
123
+ | { question: AskQuestion }
124
+ | {
125
+ resume: {
126
+ sessionId: string;
127
+ /** The conversation as last saved; the agent's replay fills in
128
+ * one saved without it. */
129
+ entries?: AskEntry[];
130
+ };
131
+ }
132
+ );
133
+
134
+ /** How long an agent may take to start and open its session. */
135
+ const START_TIMEOUT_MS = 60_000;
136
+
137
+ /** How long a turn may run on after Stop before Whiteboard stops its agent.
138
+ * Claude's adapter ends a wedged turn itself after 30 seconds; Codex's does not. */
139
+ const STOP_GRACE_MS = 15_000;
140
+
141
+ /** How long an agent may take to start, and to stop a turn when asked. */
142
+ export interface AskThreadLimits {
143
+ startMs: number;
144
+ stopGraceMs: number;
145
+ }
146
+
147
+ export const askThreadLimits: AskThreadLimits = {
148
+ startMs: START_TIMEOUT_MS,
149
+ stopGraceMs: STOP_GRACE_MS,
150
+ };
151
+
152
+ /** One Ask conversation: an agent process and one ACP session in the review's checkout. */
153
+ export class AskThread {
154
+ readonly id: string;
155
+ private state: AskThreadState;
156
+ /** The number of changes so far; a snapshot carries the one it includes. */
157
+ private seq = 0;
158
+ private readonly listeners = new Set<(update: AskUpdate) => void>();
159
+ private readonly closers = new Set<() => void>();
160
+ private readonly decisions = new Map<
161
+ string,
162
+ (response: RequestPermissionResponse) => void
163
+ >();
164
+ private process?: AskAgentProcess;
165
+ private connection?: ClientConnection;
166
+ /** Counts connections, so a start that was given up on cannot take over. */
167
+ private generation = 0;
168
+ /** What the last agent process said on stderr, once it is gone. */
169
+ private stderr = "";
170
+ private sessionId?: string;
171
+ private closed = false;
172
+ /** The MCP server behind each tool call an adapter reported one for. */
173
+ private readonly mcpCalls = new Map<string, string>();
174
+ private readonly mcpServers: McpServer[];
175
+ /** Loading an earlier conversation: the agent replays it as updates. */
176
+ private replaying = false;
177
+ /** Replayed user messages as sent, context included. */
178
+ private readonly replayedUser = new Map<string, string>();
179
+ /** The panel already shows the conversation, so a load's replay is not
180
+ * needed: a saved copy, or the thread itself when it tries again. */
181
+ private shown: boolean;
182
+ /** The config option that sets each choice the agent offers. */
183
+ private readonly configIds = new Map<AskChoiceKind, string>();
184
+ /** The agent's other settings as last seen, to say when it changes one
185
+ * itself, as Codex's /plan does. */
186
+ private readonly settings = new Map<string, string>();
187
+ /** The model and effort in use, which a new agent process starts with. */
188
+ private readonly picks: AskPicks;
189
+ /** The question being answered, until its turn ends; after a failure,
190
+ * the one trying again asks. */
191
+ private asking?: { id: string; question: AskQuestion };
192
+ /** The session has not had the selection yet: a new conversation, or a
193
+ * new session for one the agent could not reopen. */
194
+ private needsContext: boolean;
195
+ /** The reviewer changed what the agent may do since it was last told. */
196
+ private permitted = false;
197
+ /** The agent could not reopen the session, for a reason signing in would
198
+ * not fix, so trying again starts a new one. */
199
+ private unloadable = false;
200
+ /** Stop ended the agent itself: a start, or a turn that would not stop. */
201
+ private halted = false;
202
+ private stopTimer?: ReturnType<typeof setTimeout>;
203
+ /** What Pi greets a new session with, its version and skills, which is no
204
+ * part of the answer. */
205
+ private greeting?: string;
206
+
207
+ constructor(
208
+ private readonly launch: AskAgentLauncher,
209
+ private readonly start: AskThreadStart,
210
+ mcpServers: AskMcpServers = () => [],
211
+ private readonly limits: AskThreadLimits = askThreadLimits,
212
+ ) {
213
+ this.id = start.id ?? randomUUID();
214
+ this.mcpServers = mcpServers();
215
+ this.picks = { ...start.picks };
216
+ this.state = {
217
+ id: this.id,
218
+ agent: start.agent,
219
+ agentName: askAgents[start.agent].name,
220
+ status: "starting",
221
+ readOnly: false,
222
+ bypass: start.bypass ?? false,
223
+ head: start.head,
224
+ cwd: start.cwd,
225
+ selection: start.selection,
226
+ entries: ("resume" in start && start.resume.entries) || [],
227
+ };
228
+
229
+ if ("resume" in start) this.sessionId = start.resume.sessionId;
230
+ this.shown = "resume" in start && Boolean(start.resume.entries);
231
+ this.needsContext = !("resume" in start);
232
+ }
233
+
234
+ get reviewId() {
235
+ return this.start.reviewId;
236
+ }
237
+
238
+ read(): AskThreadState {
239
+ return this.state;
240
+ }
241
+
242
+ snapshot(): AskUpdate {
243
+ return { seq: this.seq, snapshot: this.state };
244
+ }
245
+
246
+ /** Every change after the current `seq`, in order. */
247
+ subscribe(listener: (update: AskUpdate) => void) {
248
+ this.listeners.add(listener);
249
+
250
+ return () => this.listeners.delete(listener);
251
+ }
252
+
253
+ /** Starts the agent and asks the first question, or loads an earlier
254
+ * conversation; failures land in the state. */
255
+ async open() {
256
+ const start = this.start;
257
+
258
+ if ("resume" in start) {
259
+ await this.attempt(async () => {
260
+ await this.connect();
261
+ this.emit({ type: "set", status: "idle" });
262
+ });
263
+
264
+ return;
265
+ }
266
+
267
+ this.asking = {
268
+ id: this.addUser(start.question),
269
+ question: start.question,
270
+ };
271
+ await this.attempt(async () => {
272
+ await this.connect();
273
+ await this.prompt(start.question);
274
+ });
275
+ }
276
+
277
+ /** Why a question cannot be asked now, if it cannot. */
278
+ askRefusal(): string | undefined {
279
+ switch (this.state.status) {
280
+ case "idle":
281
+ return undefined;
282
+ case "failed":
283
+ return `${this.state.agentName} stopped. Try again first.`;
284
+ default:
285
+ return "The agent is still answering.";
286
+ }
287
+ }
288
+
289
+ /** Asks a follow-up, starting the agent again if it has stopped since. */
290
+ async ask(question: AskQuestion) {
291
+ const refusal = this.askRefusal();
292
+
293
+ if (refusal) throw new Error(refusal);
294
+
295
+ this.asking = { id: this.addUser(question), question };
296
+ await this.attempt(async () => {
297
+ await this.reconnect();
298
+ await this.prompt(question);
299
+ });
300
+ }
301
+
302
+ /** Starts the agent again after it failed, as when its login lapsed, and
303
+ * asks again the question it did not answer, if one failed. */
304
+ async retry() {
305
+ if (this.state.status !== "failed")
306
+ throw new Error("Only a conversation that failed can try again.");
307
+
308
+ this.disconnect();
309
+
310
+ const asked = this.state.entries.findIndex(
311
+ (entry) => entry.kind === "user" && entry.id === this.asking?.id,
312
+ );
313
+
314
+ const question = asked === -1 ? undefined : this.asking?.question;
315
+
316
+ // What the failed turn left, such as the agent's own login notice. A
317
+ // reopen that failed left nothing: its last answer stays.
318
+ const partial = asked === -1 ? [] : this.state.entries.slice(asked + 1);
319
+
320
+ if (partial.length)
321
+ this.emit({ type: "remove", ids: partial.map((entry) => entry.id) });
322
+
323
+ if (this.unloadable) {
324
+ this.unloadable = false;
325
+ this.sessionId = undefined;
326
+ this.needsContext = true;
327
+ }
328
+
329
+ this.emit({ type: "set", status: "starting", error: null, signIn: null });
330
+ await this.attempt(async () => {
331
+ await this.connect();
332
+
333
+ if (question) await this.prompt(question);
334
+ else this.emit({ type: "set", status: "idle" });
335
+ });
336
+ }
337
+
338
+ decide(permissionId: string, optionId: string) {
339
+ const resolve = this.decisions.get(permissionId);
340
+
341
+ if (!resolve) return false;
342
+ resolve({ outcome: { outcome: "selected", optionId } });
343
+
344
+ return true;
345
+ }
346
+
347
+ /** Stops the turn, or the start, in progress. A turn the agent does not
348
+ * end soon after is ended by stopping the agent. */
349
+ async cancel() {
350
+ // ACP: the Client answers every pending permission request as cancelled.
351
+ for (const resolve of this.decisions.values())
352
+ resolve({ outcome: { outcome: "cancelled" } });
353
+
354
+ const { status } = this.state;
355
+
356
+ if (status === "starting") {
357
+ this.halt();
358
+
359
+ return;
360
+ }
361
+
362
+ const { connection, sessionId } = this;
363
+
364
+ if ((status !== "running" && status !== "waiting") || !connection) return;
365
+
366
+ clearTimeout(this.stopTimer);
367
+ this.stopTimer = setTimeout(() => {
368
+ if (this.connection === connection) this.halt();
369
+ }, this.limits.stopGraceMs);
370
+
371
+ if (sessionId)
372
+ await connection.agent
373
+ .notify(methods.agent.session.cancel, { sessionId })
374
+ .catch(() => {});
375
+ }
376
+
377
+ close() {
378
+ if (this.closed) return;
379
+ this.closed = true;
380
+
381
+ // A conversation still starting may be half replayed, and one that
382
+ // failed has nothing new; neither replaces what was saved.
383
+ if (
384
+ this.sessionId &&
385
+ this.state.status !== "starting" &&
386
+ this.state.status !== "failed"
387
+ )
388
+ this.start.onSave?.(settled(this.state.entries));
389
+
390
+ for (const resolve of this.decisions.values())
391
+ resolve({ outcome: { outcome: "cancelled" } });
392
+
393
+ if (this.connection && this.sessionId)
394
+ void this.connection.agent
395
+ .notify(methods.agent.session.cancel, { sessionId: this.sessionId })
396
+ .catch(() => {});
397
+ this.disconnect();
398
+ this.listeners.clear();
399
+
400
+ for (const closed of this.closers) closed();
401
+ this.closers.clear();
402
+ }
403
+
404
+ /** Runs when the thread closes, at once if it already has. */
405
+ onClose(closed: () => void) {
406
+ if (this.closed) {
407
+ closed();
408
+
409
+ return () => {};
410
+ }
411
+
412
+ this.closers.add(closed);
413
+
414
+ return () => this.closers.delete(closed);
415
+ }
416
+
417
+ /** Runs a step that talks to the agent; failures land in the state. */
418
+ private async attempt(step: () => Promise<void>) {
419
+ this.halted = false;
420
+
421
+ try {
422
+ await step();
423
+ } catch (error) {
424
+ if (this.closed) return;
425
+
426
+ if (this.halted) this.stopped();
427
+ else this.fail(error);
428
+ }
429
+ }
430
+
431
+ /** Stop ended the agent before a turn could: nothing is running now, and
432
+ * the next question starts the agent again. */
433
+ private stopped() {
434
+ this.halted = false;
435
+
436
+ if (this.asking)
437
+ this.push({
438
+ kind: "notice",
439
+ id: randomUUID(),
440
+ severity: "info",
441
+ title: "Stopped here.",
442
+ });
443
+ this.asking = undefined;
444
+ this.emit({ type: "set", status: "idle", error: null });
445
+ }
446
+
447
+ /** Ends the agent process, failing whatever was waiting on it. */
448
+ private halt() {
449
+ this.halted = true;
450
+ this.disconnect();
451
+ }
452
+
453
+ private disconnect() {
454
+ this.generation += 1;
455
+ clearTimeout(this.stopTimer);
456
+
457
+ if (this.process) this.stderr = this.process.diagnostics();
458
+ this.connection?.close();
459
+ this.process?.stop();
460
+ this.connection = undefined;
461
+ this.process = undefined;
462
+ }
463
+
464
+ /** Starts the agent again if it stopped since the last turn. */
465
+ private async reconnect() {
466
+ if (this.connection && !this.connection.signal.aborted) return;
467
+
468
+ this.disconnect();
469
+ this.emit({ type: "set", status: "starting" });
470
+ await this.connect();
471
+ }
472
+
473
+ /** Starts the agent and opens the session, or gives up after a while. */
474
+ private async connect() {
475
+ let timer: ReturnType<typeof setTimeout> | undefined;
476
+
477
+ const expired = new Promise<never>((_resolve, reject) => {
478
+ timer = setTimeout(
479
+ () =>
480
+ reject(
481
+ new Error(
482
+ `${this.state.agentName} did not start within ${Math.round(this.limits.startMs / 1000)} seconds.`,
483
+ ),
484
+ ),
485
+ this.limits.startMs,
486
+ );
487
+ });
488
+
489
+ const starting = this.startAgent();
490
+
491
+ // A start given up on fails later, once its agent is stopped.
492
+ starting.catch(() => {});
493
+
494
+ try {
495
+ await Promise.race([starting, expired]);
496
+ } catch (error) {
497
+ this.disconnect();
498
+
499
+ throw error;
500
+ } finally {
501
+ clearTimeout(timer);
502
+ }
503
+ }
504
+
505
+ private async startAgent() {
506
+ const generation = ++this.generation;
507
+
508
+ const launched = await this.launch(this.start.agent, this.start.cwd, {
509
+ bypass: this.state.bypass,
510
+ mcpServers: this.mcpServers,
511
+ });
512
+
513
+ // Closed, stopped or given up on while the process started.
514
+ if (this.closed || this.halted || generation !== this.generation) {
515
+ launched.stop();
516
+
517
+ throw new Error(`${this.state.agentName} was stopped while it started.`);
518
+ }
519
+
520
+ this.process = launched;
521
+
522
+ // Advertise no file system or terminal: the agent reads the checkout itself.
523
+ const connection = launched.connect(
524
+ client({ name: "whiteboard" })
525
+ .onRequest(
526
+ methods.client.session.requestPermission,
527
+ ({ params, signal }) => this.requestPermission(params, signal),
528
+ )
529
+ .onNotification(methods.client.session.update, ({ params }) =>
530
+ this.update(params),
531
+ )
532
+ // Cursor waits on an answer to its todo list before it carries on.
533
+ .onRequest("cursor/update_todos", cursorTodosSchema, ({ params }) => ({
534
+ outcome: { outcome: "accepted", todos: params.todos },
535
+ })),
536
+ );
537
+
538
+ this.connection = connection;
539
+
540
+ const agent = connection.agent;
541
+
542
+ const initialized = await agent.request(methods.agent.initialize, {
543
+ protocolVersion: PROTOCOL_VERSION,
544
+ // Notices keep an agent's asides about itself (Codex's warnings about
545
+ // its own config) out of the answer's text.
546
+ // Cursor offers its models as config options only when asked to.
547
+ clientCapabilities: {
548
+ session: { notices: {} },
549
+ _meta: { parameterizedModelPicker: true },
550
+ },
551
+ clientInfo: { name: "whiteboard", title: "Whiteboard", version: "1" },
552
+ });
553
+
554
+ this.emit({
555
+ type: "set",
556
+ accepts: {
557
+ image:
558
+ initialized.agentCapabilities?.promptCapabilities?.image === true,
559
+ },
560
+ });
561
+
562
+ const session = await this.session(
563
+ connection,
564
+ generation,
565
+ initialized.agentCapabilities?.loadSession === true,
566
+ );
567
+
568
+ const { bypass } = this.state;
569
+ const spec = askAgents[this.start.agent];
570
+ const mode = bypass ? spec.bypass?.mode : spec.readOnlyMode;
571
+
572
+ // An agent without a read-only mode still answers, as it is.
573
+ const configurable =
574
+ mode !== undefined &&
575
+ session.response.configOptions?.some(
576
+ (option) =>
577
+ option.id === "mode" &&
578
+ option.type === "select" &&
579
+ selectOptionsSchema
580
+ .safeParse(option.options)
581
+ .data?.flatMap((choice) =>
582
+ "group" in choice ? choice.options : [choice],
583
+ )
584
+ .some((choice) => choice.value === mode),
585
+ );
586
+
587
+ const selectable =
588
+ mode !== undefined &&
589
+ session.response.modes?.availableModes.some(
590
+ (available) => available.id === mode,
591
+ );
592
+
593
+ let config = session.response.configOptions;
594
+
595
+ if (configurable)
596
+ config =
597
+ (
598
+ await agent.request(methods.agent.session.setConfigOption, {
599
+ sessionId: session.sessionId,
600
+ configId: "mode",
601
+ value: mode,
602
+ })
603
+ ).configOptions ?? config;
604
+ else if (selectable)
605
+ await agent.request(methods.agent.session.setMode, {
606
+ sessionId: session.sessionId,
607
+ modeId: mode,
608
+ });
609
+
610
+ this.emit({
611
+ type: "set",
612
+ readOnly: !bypass && Boolean(configurable || selectable),
613
+ });
614
+ this.useConfig(config);
615
+
616
+ // The model first: the efforts on offer depend on it. A pick the agent
617
+ // no longer offers keeps its default.
618
+ for (const kind of askChoiceKinds) {
619
+ const picked = this.picks[kind];
620
+ const select = this.state.choices?.[kind];
621
+
622
+ if (
623
+ picked &&
624
+ picked !== select?.current &&
625
+ select?.options.some((option) => option.value === picked)
626
+ )
627
+ await this.change(kind, picked);
628
+ }
629
+ }
630
+
631
+ /** Answers the next question with another model or effort, starting the
632
+ * agent again if it has stopped since. */
633
+ async choose(kind: AskChoiceKind, value: string) {
634
+ if (this.state.status !== "idle")
635
+ throw new Error("Settings can change between answers.");
636
+
637
+ if (!this.connection || this.connection.signal.aborted) {
638
+ await this.attempt(async () => {
639
+ await this.reconnect();
640
+ this.emit({ type: "set", status: "idle" });
641
+ });
642
+
643
+ if (this.state.status !== "idle") return;
644
+ }
645
+
646
+ await this.change(kind, value);
647
+ }
648
+
649
+ private async change(kind: AskChoiceKind, value: string) {
650
+ const configId = this.configIds.get(kind);
651
+
652
+ if (!this.connection || !this.sessionId || !configId)
653
+ throw new Error(`${this.state.agentName} offers no such choice.`);
654
+
655
+ const response = await this.connection.agent.request(
656
+ methods.agent.session.setConfigOption,
657
+ { sessionId: this.sessionId, configId, value },
658
+ );
659
+
660
+ this.picks[kind] = value;
661
+ this.useConfig(response.configOptions);
662
+ }
663
+
664
+ private useConfig(options: SessionConfigOption[] | null | undefined) {
665
+ const found = choicesOf(options);
666
+
667
+ this.noteSettings(options, found);
668
+
669
+ if (!found.size) return;
670
+ const choices: AskChoices = {};
671
+
672
+ for (const [kind, { configId, select }] of found) {
673
+ this.configIds.set(kind, configId);
674
+ choices[kind] = select;
675
+ }
676
+
677
+ this.emit({ type: "set", choices });
678
+ this.announce();
679
+ }
680
+
681
+ /** Says when a setting the panel does not show changes, which can be
682
+ * all a command does. */
683
+ private noteSettings(
684
+ options: SessionConfigOption[] | null | undefined,
685
+ choices: ReturnType<typeof choicesOf>,
686
+ ) {
687
+ const shown = new Set(
688
+ [...choices.values()].map(({ configId }) => configId),
689
+ );
690
+
691
+ for (const option of options ?? []) {
692
+ if (option.type !== "select" || shown.has(option.id)) continue;
693
+ const before = this.settings.get(option.id);
694
+
695
+ this.settings.set(option.id, option.currentValue);
696
+
697
+ if (before === undefined || before === option.currentValue) continue;
698
+
699
+ const value =
700
+ selectOptionsSchema
701
+ .safeParse(option.options)
702
+ .data?.flatMap((choice) =>
703
+ "group" in choice ? choice.options : [choice],
704
+ )
705
+ .find((choice) => choice.value === option.currentValue)?.name ??
706
+ option.currentValue;
707
+
708
+ this.push({
709
+ kind: "notice",
710
+ id: randomUUID(),
711
+ severity: "info",
712
+ title: `${this.state.agentName} set ${option.name.toLowerCase()} to ${value}.`,
713
+ });
714
+ }
715
+ }
716
+
717
+ /** Tells the host what the agent offers now, once its settings are
718
+ * known: a reopening agent lists its commands first, and what it says
719
+ * then must not stand as an offer of no model. */
720
+ private announce() {
721
+ const { choices, commands, accepts } = this.state;
722
+
723
+ if (!choices) return;
724
+ const offer: AskOffer = { choices };
725
+
726
+ if (commands) offer.commands = commands;
727
+
728
+ if (accepts) offer.accepts = accepts;
729
+ this.start.onOffer?.(offer);
730
+ }
731
+
732
+ /** Edits and runs commands without asking from the next answer, or stops:
733
+ * the agent starts again in that mode and reopens the conversation. */
734
+ async permit(bypass: boolean) {
735
+ if (this.state.status !== "idle")
736
+ throw new Error("Settings can change between answers.");
737
+
738
+ if (bypass === this.state.bypass) return;
739
+
740
+ if (bypass && !askAgents[this.start.agent].bypass)
741
+ throw new Error(`${this.state.agentName} cannot bypass permissions.`);
742
+
743
+ this.emit({ type: "set", bypass });
744
+ this.permitted = true;
745
+ this.start.onBypass?.(bypass);
746
+ this.disconnect();
747
+ // Its mode is Whiteboard's change, not one to announce as the agent's.
748
+ this.settings.clear();
749
+ this.emit({ type: "set", status: "starting" });
750
+ await this.attempt(async () => {
751
+ await this.connect();
752
+ this.emit({ type: "set", status: "idle" });
753
+ });
754
+ }
755
+
756
+ /** What the session starts with: read-only, or bypassing permissions. */
757
+ private sessionMeta() {
758
+ const spec = askAgents[this.start.agent];
759
+
760
+ return (this.state.bypass && spec.bypass?.sessionMeta) || spec.sessionMeta;
761
+ }
762
+
763
+ /** The agent says which mode it is in now; only its read-only one keeps
764
+ * the checkout as it is. */
765
+ private useMode(mode: string) {
766
+ const readOnly =
767
+ !this.state.bypass && mode === askAgents[this.start.agent].readOnlyMode;
768
+
769
+ if (readOnly !== this.state.readOnly) this.emit({ type: "set", readOnly });
770
+ }
771
+
772
+ /** A new session, or the earlier one loaded with its history replayed. */
773
+ private async session(
774
+ connection: ClientConnection,
775
+ generation: number,
776
+ canLoad: boolean,
777
+ ) {
778
+ const agent = connection.agent;
779
+ const start = this.start;
780
+ const name = this.state.agentName;
781
+ // Reopening, or trying again, loads the session there is.
782
+ const earlier = this.sessionId;
783
+
784
+ if (!earlier) {
785
+ const response = await agent.request(methods.agent.session.new, {
786
+ cwd: start.cwd,
787
+ mcpServers: this.mcpServers,
788
+ _meta: this.sessionMeta(),
789
+ });
790
+
791
+ this.sessionId = response.sessionId;
792
+ start.onSession?.(response.sessionId);
793
+
794
+ this.greeting = piGreetingSchema.safeParse(
795
+ response._meta,
796
+ ).data?.piAcp.startupInfo;
797
+
798
+ return { sessionId: response.sessionId, response };
799
+ }
800
+
801
+ // Trying again cannot help: continue in a new session instead.
802
+ const unloadable = (reason: string, cause?: unknown) => {
803
+ this.unloadable = true;
804
+
805
+ return new Error(
806
+ `${name} could not reopen this conversation. ${reason} Try again to continue it in a new session, which starts without what was said before.`,
807
+ { cause },
808
+ );
809
+ };
810
+
811
+ if (!canLoad) throw unloadable(`${name} cannot reopen past conversations.`);
812
+ this.shown ||= this.state.entries.length > 0;
813
+ // The replay arrives before the response, addressed to this session.
814
+ this.replaying = true;
815
+
816
+ try {
817
+ const response = await agent.request(methods.agent.session.load, {
818
+ sessionId: earlier,
819
+ cwd: start.cwd,
820
+ mcpServers: this.mcpServers,
821
+ _meta: this.sessionMeta(),
822
+ });
823
+
824
+ return { sessionId: earlier, response };
825
+ } catch (error) {
826
+ // A lapsed login, Stop, or a start given up on: the session may
827
+ // still be there.
828
+ if (
829
+ signedOut(error) ||
830
+ this.closed ||
831
+ this.halted ||
832
+ generation !== this.generation
833
+ )
834
+ throw error;
835
+
836
+ throw unloadable(errorMessage(error), error);
837
+ } finally {
838
+ this.replaying = false;
839
+ }
840
+ }
841
+
842
+ /** Asks a question in the session, with the selection first if the
843
+ * session has not had it. */
844
+ private async prompt(question: AskQuestion) {
845
+ const { connection, sessionId } = this;
846
+
847
+ if (!connection || !sessionId)
848
+ throw new Error(`${this.state.agentName} is not running.`);
849
+
850
+ // Agents read a slash command only at the start of a prompt, so it goes
851
+ // alone; the selection goes with the next question instead.
852
+ const command = question.text.startsWith("/");
853
+ const withSelection = this.needsContext && !command;
854
+ // What the agent may do goes with the selection, and again once changed.
855
+ const withPermission = (withSelection || this.permitted) && !command;
856
+
857
+ const context = [
858
+ ...(withSelection ? [this.start.context] : []),
859
+ ...(withPermission
860
+ ? [
861
+ this.state.bypass
862
+ ? "The reviewer lets you change files in the checkout and run commands without asking."
863
+ : "Do not change files in the checkout.",
864
+ ]
865
+ : []),
866
+ ].join("\n");
867
+
868
+ this.emit({ type: "set", status: "running", error: null });
869
+
870
+ const prompt: ContentBlock[] = [
871
+ ...(context
872
+ ? withContext(context, question.text)
873
+ : [{ type: "text" as const, text: question.text }]),
874
+ ...this.attachments(question),
875
+ ];
876
+
877
+ let stopReason: StopReason;
878
+
879
+ try {
880
+ ({ stopReason } = await connection.agent.request(
881
+ methods.agent.session.prompt,
882
+ { sessionId, prompt },
883
+ ));
884
+ } catch (error) {
885
+ // Stop ended an agent that would not stop the turn itself.
886
+ if (!this.halted) throw error;
887
+ stopReason = "cancelled";
888
+ this.halted = false;
889
+ } finally {
890
+ clearTimeout(this.stopTimer);
891
+ }
892
+
893
+ if (withSelection) this.needsContext = false;
894
+
895
+ if (withPermission) this.permitted = false;
896
+ this.asking = undefined;
897
+
898
+ const asked = this.state.entries.findLastIndex(
899
+ (entry) => entry.kind === "user",
900
+ );
901
+
902
+ // An answer cut off by Stop ends mid-sentence; say where it stopped.
903
+ if (stopReason === "cancelled")
904
+ this.push({
905
+ kind: "notice",
906
+ id: randomUUID(),
907
+ severity: "info",
908
+ title: "Stopped here.",
909
+ });
910
+ // A command can end the turn having said nothing.
911
+ else if (
912
+ stopReason === "end_turn" &&
913
+ asked === this.state.entries.length - 1
914
+ )
915
+ this.push({
916
+ kind: "notice",
917
+ id: randomUUID(),
918
+ severity: "info",
919
+ title: `${this.state.agentName} finished without replying.`,
920
+ });
921
+
922
+ this.emit({
923
+ type: "set",
924
+ status: "idle",
925
+ error:
926
+ stopReason === "refusal"
927
+ ? `${this.state.agentName} declined to answer.`
928
+ : stopReason === "max_tokens" || stopReason === "max_turn_requests"
929
+ ? `${this.state.agentName} stopped before finishing.`
930
+ : null,
931
+ });
932
+ this.start.onTurn?.();
933
+ this.start.onSave?.(this.state.entries);
934
+ }
935
+
936
+ private async requestPermission(
937
+ request: RequestPermissionRequest,
938
+ /** Aborts when the agent withdraws the request, or goes away. */
939
+ signal: AbortSignal,
940
+ ): Promise<RequestPermissionResponse> {
941
+ const id = request.toolCall.toolCallId;
942
+ const toolKind = request.toolCall.kind ?? "other";
943
+ const server = mcpServerOf(request.toolCall) ?? this.mcpCalls.get(id);
944
+
945
+ const allowOnce = request.options.find(
946
+ (option) => option.kind === "allow_once",
947
+ );
948
+
949
+ // Bypassing permissions, everything runs; otherwise Whiteboard's own
950
+ // tools, and tools that only read. Allow once, never "always": an adapter
951
+ // may save an "always" rule into the checkout's settings.
952
+ if (
953
+ allowOnce &&
954
+ (this.state.bypass ||
955
+ (server &&
956
+ (READ_ONLY_MCP_SERVERS.has(server) ||
957
+ this.mcpServers.some((provided) => provided.name === server))))
958
+ )
959
+ return { outcome: { outcome: "selected", optionId: allowOnce.optionId } };
960
+
961
+ // Codex titles a command's request only "Run command"; what it would run
962
+ // is in the request, or in the tool call it started.
963
+ const input =
964
+ toolDetails(request.toolCall).input ??
965
+ this.state.entries.find(
966
+ (entry): entry is ToolEntry => entry.kind === "tool" && entry.id === id,
967
+ )?.input;
968
+
969
+ const entry: PermissionEntry = {
970
+ kind: "permission",
971
+ id,
972
+ title: request.toolCall.title ?? "Run a tool",
973
+ toolKind,
974
+ options: request.options,
975
+ };
976
+
977
+ if (input) entry.input = input;
978
+
979
+ if (REFUSED_KINDS.has(toolKind)) {
980
+ const reject = request.options.find(
981
+ (option) => option.kind === "reject_once",
982
+ );
983
+
984
+ this.push({
985
+ ...entry,
986
+ outcome: reject?.optionId ?? "cancelled",
987
+ automatic: true,
988
+ });
989
+
990
+ return reject
991
+ ? { outcome: { outcome: "selected", optionId: reject.optionId } }
992
+ : { outcome: { outcome: "cancelled" } };
993
+ }
994
+
995
+ this.push(entry);
996
+ this.emit({ type: "set", status: "waiting" });
997
+
998
+ const response = await new Promise<RequestPermissionResponse>((resolve) => {
999
+ this.decisions.set(id, resolve);
1000
+
1001
+ // Nothing waits on a request its agent withdrew: it is no longer
1002
+ // the reviewer's to answer.
1003
+ const withdrawn = () => resolve({ outcome: { outcome: "cancelled" } });
1004
+
1005
+ if (signal.aborted) withdrawn();
1006
+ else signal.addEventListener("abort", withdrawn, { once: true });
1007
+ });
1008
+
1009
+ this.decisions.delete(id);
1010
+ this.replace(id, "permission", (current) => ({
1011
+ ...current,
1012
+ outcome:
1013
+ response.outcome.outcome === "selected"
1014
+ ? response.outcome.optionId
1015
+ : "cancelled",
1016
+ }));
1017
+
1018
+ // The agent carries on, unless it still waits on another answer.
1019
+ if (this.state.status === "waiting" && !this.decisions.size)
1020
+ this.emit({ type: "set", status: "running" });
1021
+
1022
+ return response;
1023
+ }
1024
+
1025
+ private update({ sessionId, update }: SessionNotification) {
1026
+ if (sessionId !== this.sessionId) return;
1027
+ this.apply(update);
1028
+ }
1029
+
1030
+ private apply(update: SessionUpdate) {
1031
+ if (this.useSessionState(update)) return;
1032
+
1033
+ // The saved copy already shows what the agent replays.
1034
+ if (this.replaying && this.shown) return;
1035
+
1036
+ switch (update.sessionUpdate) {
1037
+ // Only a loaded conversation replays what the reviewer asked.
1038
+ case "user_message_chunk":
1039
+ if (this.replaying && update.content.type === "text")
1040
+ this.replayUser(update.content.text, update.messageId ?? undefined);
1041
+
1042
+ return;
1043
+ case "agent_message_chunk": {
1044
+ if (update.content.type !== "text") return;
1045
+
1046
+ const { text } = update.content;
1047
+
1048
+ if (text === this.greeting) {
1049
+ this.greeting = undefined;
1050
+
1051
+ return;
1052
+ }
1053
+
1054
+ const last = this.state.entries.at(-1);
1055
+ const id = update.messageId ?? undefined;
1056
+
1057
+ if (last?.kind === "agent" && (!id || last.id === id)) {
1058
+ this.emit({ type: "append", id: last.id, text });
1059
+
1060
+ return;
1061
+ }
1062
+
1063
+ this.push({ kind: "agent", id: id ?? randomUUID(), text });
1064
+
1065
+ return;
1066
+ }
1067
+
1068
+ case "config_option_update": {
1069
+ this.useConfig(update.configOptions);
1070
+
1071
+ const mode = update.configOptions.find(
1072
+ (option) => option.id === "mode" && option.type === "select",
1073
+ );
1074
+
1075
+ if (mode?.type === "select") this.useMode(mode.currentValue);
1076
+
1077
+ return;
1078
+ }
1079
+
1080
+ case "current_mode_update":
1081
+ this.useMode(update.currentModeId);
1082
+
1083
+ return;
1084
+ case "notice": {
1085
+ // Some agents repeat a warning every turn; once is enough.
1086
+ if (
1087
+ this.state.entries.some(
1088
+ (entry) =>
1089
+ entry.kind === "notice" &&
1090
+ entry.title === update.title &&
1091
+ entry.description === (update.description ?? undefined),
1092
+ )
1093
+ )
1094
+ return;
1095
+
1096
+ const notice: NoticeEntry = {
1097
+ kind: "notice",
1098
+ id: randomUUID(),
1099
+ severity: update.severity,
1100
+ title: update.title,
1101
+ };
1102
+
1103
+ if (update.description) notice.description = update.description;
1104
+ this.push(notice);
1105
+
1106
+ return;
1107
+ }
1108
+
1109
+ case "tool_call":
1110
+ this.noteMcpServer(update);
1111
+ this.push({
1112
+ kind: "tool",
1113
+ id: update.toolCallId,
1114
+ title: update.title,
1115
+ toolKind: update.kind ?? "other",
1116
+ status: update.status ?? "pending",
1117
+ ...toolDetails(update),
1118
+ });
1119
+ this.showPlan(update);
1120
+
1121
+ return;
1122
+ case "tool_call_update":
1123
+ this.noteMcpServer(update);
1124
+
1125
+ // An agent may report a tool call's first state as an update.
1126
+ if (
1127
+ !this.state.entries.some(
1128
+ (entry) => entry.kind === "tool" && entry.id === update.toolCallId,
1129
+ )
1130
+ ) {
1131
+ this.push({
1132
+ kind: "tool",
1133
+ id: update.toolCallId,
1134
+ title: update.title ?? "Tool call",
1135
+ toolKind: update.kind ?? "other",
1136
+ status: update.status ?? "pending",
1137
+ ...toolDetails(update),
1138
+ });
1139
+ this.showPlan(update);
1140
+
1141
+ return;
1142
+ }
1143
+
1144
+ this.replace(update.toolCallId, "tool", (current) => {
1145
+ const status = update.status ?? current.status;
1146
+
1147
+ const output =
1148
+ status === "completed" || status === "failed"
1149
+ ? toolOutput(update)
1150
+ : undefined;
1151
+
1152
+ const next = {
1153
+ ...current,
1154
+ title: update.title ?? current.title,
1155
+ toolKind: update.kind ?? current.toolKind,
1156
+ status,
1157
+ // The complete input can come after the call starts.
1158
+ ...toolDetails(update),
1159
+ };
1160
+
1161
+ if (output) next.output = output;
1162
+
1163
+ return next;
1164
+ });
1165
+ this.showPlan(update);
1166
+
1167
+ return;
1168
+ default:
1169
+ }
1170
+ }
1171
+
1172
+ /** What the session says about itself rather than the turn: its
1173
+ * commands, how full it is, and its name. A reload's replay keeps them. */
1174
+ private useSessionState(update: SessionUpdate) {
1175
+ switch (update.sessionUpdate) {
1176
+ case "available_commands_update":
1177
+ this.emit({
1178
+ type: "set",
1179
+ commands: update.availableCommands.map(commandOf),
1180
+ });
1181
+ this.announce();
1182
+
1183
+ return true;
1184
+ case "usage_update": {
1185
+ const { used, size, cost } = update;
1186
+
1187
+ this.emit({
1188
+ type: "set",
1189
+ usage: cost
1190
+ ? {
1191
+ used,
1192
+ size,
1193
+ cost: { amount: cost.amount, currency: cost.currency },
1194
+ }
1195
+ : { used, size },
1196
+ });
1197
+
1198
+ return true;
1199
+ }
1200
+
1201
+ case "session_info_update": {
1202
+ const title = update.title?.trim();
1203
+
1204
+ if (title && title !== this.state.title) {
1205
+ this.emit({ type: "set", title });
1206
+ this.start.onTitle?.(title);
1207
+ }
1208
+
1209
+ return true;
1210
+ }
1211
+
1212
+ default:
1213
+ return false;
1214
+ }
1215
+ }
1216
+
1217
+ private noteMcpServer(update: ToolCallUpdate) {
1218
+ const server = mcpServerOf(update);
1219
+
1220
+ if (server) this.mcpCalls.set(update.toolCallId, server);
1221
+ }
1222
+
1223
+ /**
1224
+ * In its read-only mode Claude answers by writing a plan and asking to
1225
+ * leave the mode with it. That request is refused, so the plan is shown
1226
+ * as the answer; the refusal's own text, once it comes, is not.
1227
+ */
1228
+ private showPlan(update: ToolCallUpdate) {
1229
+ const call = this.state.entries.find(
1230
+ (entry) => entry.kind === "tool" && entry.id === update.toolCallId,
1231
+ );
1232
+
1233
+ if (
1234
+ call?.kind !== "tool" ||
1235
+ call.toolKind !== "switch_mode" ||
1236
+ call.status === "completed" ||
1237
+ call.status === "failed"
1238
+ )
1239
+ return;
1240
+
1241
+ const plan = (update.content ?? [])
1242
+ .flatMap((item) =>
1243
+ item.type === "content" && item.content.type === "text"
1244
+ ? [item.content.text]
1245
+ : [],
1246
+ )
1247
+ .join("\n\n")
1248
+ .trim();
1249
+
1250
+ if (!plan) return;
1251
+ const id = `plan:${update.toolCallId}`;
1252
+
1253
+ if (this.state.entries.some((entry) => entry.id === id))
1254
+ this.replace(id, "agent", (current) => ({ ...current, text: plan }));
1255
+ else this.push({ kind: "agent", id, text: plan });
1256
+ }
1257
+
1258
+ /** Chunks of one replayed message join, like an answer's. */
1259
+ private replayUser(text: string, messageId: string | undefined) {
1260
+ const last = this.state.entries.at(-1);
1261
+
1262
+ if (
1263
+ last?.kind === "user" &&
1264
+ this.replayedUser.has(last.id) &&
1265
+ (!messageId || last.id === messageId)
1266
+ ) {
1267
+ const sent = this.replayedUser.get(last.id) + text;
1268
+
1269
+ this.replayedUser.set(last.id, sent);
1270
+ this.emit({
1271
+ type: "entry",
1272
+ entry: { ...last, text: withoutContext(sent) },
1273
+ });
1274
+
1275
+ return;
1276
+ }
1277
+
1278
+ const id = messageId ?? randomUUID();
1279
+
1280
+ this.replayedUser.set(id, text);
1281
+ this.push({ kind: "user", id, text: withoutContext(text) });
1282
+ }
1283
+
1284
+ private addUser(question: AskQuestion) {
1285
+ const id = randomUUID();
1286
+ const attachments = attachmentsOf(question);
1287
+
1288
+ const entry: Extract<AskEntry, { kind: "user" }> = {
1289
+ kind: "user",
1290
+ id,
1291
+ text: question.text,
1292
+ at: Date.now(),
1293
+ };
1294
+
1295
+ if (attachments.length) entry.attachments = attachments;
1296
+ this.push(entry);
1297
+
1298
+ return id;
1299
+ }
1300
+
1301
+ /** A question's files, as links into the checkout, and its images, for
1302
+ * an agent that reads them. */
1303
+ private attachments(question: AskQuestion): ContentBlock[] {
1304
+ const root = path.resolve(this.start.cwd);
1305
+ const blocks: ContentBlock[] = [];
1306
+
1307
+ for (const mention of question.mentions ?? []) {
1308
+ const file = path.resolve(root, mention);
1309
+
1310
+ // Only the checkout's own files.
1311
+ if (!file.startsWith(root + path.sep)) continue;
1312
+ blocks.push({
1313
+ type: "resource_link",
1314
+ uri: pathToFileURL(file).href,
1315
+ name: path.basename(file),
1316
+ title: mention,
1317
+ });
1318
+ }
1319
+
1320
+ const images = question.images ?? [];
1321
+
1322
+ if (!images.length) return blocks;
1323
+
1324
+ if (!this.state.accepts?.image) {
1325
+ this.push({
1326
+ kind: "notice",
1327
+ id: randomUUID(),
1328
+ severity: "warning",
1329
+ title: `${this.state.agentName} does not read images, so they were left out.`,
1330
+ });
1331
+
1332
+ return blocks;
1333
+ }
1334
+
1335
+ for (const { mimeType, data } of images)
1336
+ blocks.push({ type: "image", mimeType, data });
1337
+
1338
+ return blocks;
1339
+ }
1340
+
1341
+ private fail(cause: unknown) {
1342
+ if (this.closed) return;
1343
+
1344
+ if (signedOut(cause)) {
1345
+ const { name, signIn } = askAgents[this.start.agent];
1346
+
1347
+ this.emit({
1348
+ type: "set",
1349
+ status: "failed",
1350
+ error: `${name} is signed out.`,
1351
+ signIn,
1352
+ });
1353
+
1354
+ return;
1355
+ }
1356
+
1357
+ const diagnostics = (this.process?.diagnostics() ?? this.stderr).trim();
1358
+
1359
+ this.emit({
1360
+ type: "set",
1361
+ status: "failed",
1362
+ error: [errorMessage(cause), diagnostics?.split("\n").at(-1)]
1363
+ .filter(Boolean)
1364
+ .join("\n"),
1365
+ });
1366
+ }
1367
+
1368
+ private push(entry: AskEntry) {
1369
+ this.emit({ type: "add", entry });
1370
+ }
1371
+
1372
+ private replace<Kind extends AskEntry["kind"]>(
1373
+ id: string,
1374
+ kind: Kind,
1375
+ change: (
1376
+ entry: Extract<AskEntry, { kind: Kind }>,
1377
+ ) => Extract<AskEntry, { kind: Kind }>,
1378
+ ) {
1379
+ const current = this.state.entries.find(
1380
+ (entry) => entry.id === id && entry.kind === kind,
1381
+ );
1382
+
1383
+ if (current)
1384
+ this.emit({
1385
+ type: "entry",
1386
+ // SAFETY: current.kind === kind, and each kind has one entry shape.
1387
+ entry: change(current as Extract<AskEntry, { kind: Kind }>),
1388
+ });
1389
+ }
1390
+
1391
+ /** The one way the state changes, so watchers can follow it change by change. */
1392
+ private emit(change: AskChange) {
1393
+ if (this.closed) return;
1394
+ this.state = applyAskChange(this.state, change);
1395
+ this.seq += 1;
1396
+ const update = { seq: this.seq, change };
1397
+
1398
+ for (const listener of this.listeners) listener(update);
1399
+ }
1400
+ }