diffsplain 0.8.2 → 0.9.0

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 (93) hide show
  1. package/README.md +10 -5
  2. package/dist/assets/{angular-html-CphzFC_v.js → angular-html-m_m91ZYB.js} +1 -1
  3. package/dist/assets/{angular-ts-Am0-mbFw.js → angular-ts-CO5Z--Rb.js} +1 -1
  4. package/dist/assets/{apl-Cs2rPKDN.js → apl-ogYYgLUu.js} +1 -1
  5. package/dist/assets/{astro-CxKUUn9h.js → astro-DAfY3cNR.js} +1 -1
  6. package/dist/assets/{blade-DqeCa6v5.js → blade-E6RzsLUV.js} +1 -1
  7. package/dist/assets/{c-DdI6lknk.js → c-kKdxhtHF.js} +1 -1
  8. package/dist/assets/{cobol-COuAGE2X.js → cobol-3oDDyFNf.js} +1 -1
  9. package/dist/assets/{coffee-BFlYT3Lp.js → coffee-dsiILuWe.js} +1 -1
  10. package/dist/assets/{cpp-nywLU0Np.js → cpp-DYyNFq__.js} +1 -1
  11. package/dist/assets/{crystal-BWp4dz1s.js → crystal-B7dVp9TF.js} +1 -1
  12. package/dist/assets/{css-CRb9tUpU.js → css-DlxJ0aex.js} +1 -1
  13. package/dist/assets/{edge-rUYSbJy4.js → edge-BjEVphkC.js} +1 -1
  14. package/dist/assets/{elixir-DOq9ybIH.js → elixir-0xTOtMcM.js} +1 -1
  15. package/dist/assets/{elm-C8e38E5-.js → elm-iQk_Wrxq.js} +1 -1
  16. package/dist/assets/{erb-BR9Q37Et.js → erb-BO74ztiE.js} +1 -1
  17. package/dist/assets/{git-rebase-Coo2LUtB.js → git-rebase-BN667QRY.js} +1 -1
  18. package/dist/assets/{glimmer-js-C7hX9jg6.js → glimmer-js-DTGUIHeS.js} +1 -1
  19. package/dist/assets/{glimmer-ts-CZLT0F0k.js → glimmer-ts-BU3oQn2d.js} +1 -1
  20. package/dist/assets/{glsl-BDZRv6e-.js → glsl-KLiqLJi2.js} +1 -1
  21. package/dist/assets/{graphql-DyzFc3y3.js → graphql-D_idcsoJ.js} +1 -1
  22. package/dist/assets/{hack-CWnO7WsF.js → hack-BXlsgPIr.js} +1 -1
  23. package/dist/assets/{haml-CvDj-ghO.js → haml-fP0aUg4-.js} +1 -1
  24. package/dist/assets/{handlebars-BnU_YcVA.js → handlebars-C1ZEaot-.js} +1 -1
  25. package/dist/assets/{html-CjB2TKKa.js → html-D04qIuup.js} +1 -1
  26. package/dist/assets/{html-derivative-CYQqEXS6.js → html-derivative-C6ai-9QL.js} +1 -1
  27. package/dist/assets/{http-NNQbnqIx.js → http-C7bz3ZvQ.js} +1 -1
  28. package/dist/assets/{hurl-DHaAA1BS.js → hurl-jL6B6Ahr.js} +1 -1
  29. package/dist/assets/index-BPaJGi-m.js +1574 -0
  30. package/dist/assets/index-VAyBpb3u.css +2 -0
  31. package/dist/assets/{java-D1J5V2CD.js → java-BVzvLCON.js} +1 -1
  32. package/dist/assets/{javascript-C3pX-HsE.js → javascript-UDbZHxCI.js} +1 -1
  33. package/dist/assets/{jinja-BY9CIY8g.js → jinja-DB-QKo2A.js} +1 -1
  34. package/dist/assets/{jison-Cr0H89gS.js → jison-B1ZbR2FF.js} +1 -1
  35. package/dist/assets/{json-CA3kKNfq.js → json-DV4WwJzT.js} +1 -1
  36. package/dist/assets/{jsx-BuMkR-Lj.js → jsx-DS6_VTvW.js} +1 -1
  37. package/dist/assets/{julia-C2sLwg3f.js → julia-C4tevET0.js} +1 -1
  38. package/dist/assets/{just-pXpkLBXv.js → just-CWMjefzB.js} +1 -1
  39. package/dist/assets/{latex-7v3EPOdF.js → latex-tyEFf_Af.js} +1 -1
  40. package/dist/assets/{liquid-CJZq0Z0t.js → liquid-C3d1X1rL.js} +1 -1
  41. package/dist/assets/{lua-BimBzvTH.js → lua-sXvrzLQY.js} +1 -1
  42. package/dist/assets/{marko-CCZHVnQN.js → marko-CQweucif.js} +1 -1
  43. package/dist/assets/{mdc-SLECNw2A.js → mdc-TrxLWF7s.js} +1 -1
  44. package/dist/assets/{nginx-DUwtUge2.js → nginx-ChE2nSNJ.js} +1 -1
  45. package/dist/assets/{nim-HD8o_Omp.js → nim-BY1CTRfx.js} +1 -1
  46. package/dist/assets/{perl-BGMeIkNk.js → perl-Cvj2PQQH.js} +1 -1
  47. package/dist/assets/{php-BL5xuY-v.js → php-CGmHp8HW.js} +1 -1
  48. package/dist/assets/{pug-C8G1U_u8.js → pug-DM4S0bRr.js} +1 -1
  49. package/dist/assets/{qml-BGVSyw1x.js → qml-DI89Al9K.js} +1 -1
  50. package/dist/assets/{r-BDnRcclC.js → r-Bs5_4yYh.js} +1 -1
  51. package/dist/assets/{razor-I8tbEIVJ.js → razor-DimzayhZ.js} +1 -1
  52. package/dist/assets/{regexp-D0yakB9Q.js → regexp-BLACz0cx.js} +1 -1
  53. package/dist/assets/{rst-DbTCn07T.js → rst-DcWzxQh2.js} +1 -1
  54. package/dist/assets/{ruby-BwgPFIcO.js → ruby-Dlat5E8d.js} +1 -1
  55. package/dist/assets/{sas-dN4ePfXU.js → sas-CCDmMaBY.js} +1 -1
  56. package/dist/assets/{scss-T7M1A4iM.js → scss-BPiU2nu6.js} +1 -1
  57. package/dist/assets/{shellscript-HFjBHS7_.js → shellscript-CKinVNrN.js} +1 -1
  58. package/dist/assets/{shellsession-DCAGskrx.js → shellsession-Cea8Pu5d.js} +1 -1
  59. package/dist/assets/{soy-BgT96TF9.js → soy--BRAPY4-.js} +1 -1
  60. package/dist/assets/{sql-DCIQeRzx.js → sql-DjKiCyWK.js} +1 -1
  61. package/dist/assets/{stata-Dn0tad0_.js → stata-CDvo3J6y.js} +1 -1
  62. package/dist/assets/{surrealql-CCgFEQfW.js → surrealql-C7b7E5sh.js} +1 -1
  63. package/dist/assets/{svelte-ITXimG8u.js → svelte-DO0tKtiK.js} +1 -1
  64. package/dist/assets/{templ-DJVZZqPw.js → templ-DhhmR_rv.js} +1 -1
  65. package/dist/assets/{tex-Bn4N-OtH.js → tex-Cn_V-Gg4.js} +1 -1
  66. package/dist/assets/{ts-tags-CxQ4PoC4.js → ts-tags-B6c-FTmA.js} +1 -1
  67. package/dist/assets/{tsx-CxwgcjkC.js → tsx-Cvtt2sJ-.js} +1 -1
  68. package/dist/assets/{twig-CtbyTyMH.js → twig-B7ZbCUmB.js} +1 -1
  69. package/dist/assets/{typescript-d6ru12L8.js → typescript-i3C6um2B.js} +1 -1
  70. package/dist/assets/{vue-RvqAsYDm.js → vue-DyPX0Vum.js} +1 -1
  71. package/dist/assets/{vue-html-BkHDVENe.js → vue-html-D362KpK3.js} +1 -1
  72. package/dist/assets/{vue-vine-DvS3Mlkn.js → vue-vine-D34cBbwO.js} +1 -1
  73. package/dist/assets/{xml-lzTq5A-V.js → xml-DJEk7u4n.js} +1 -1
  74. package/dist/assets/{xsl-BL0KXNWI.js → xsl-CoTtf90f.js} +1 -1
  75. package/dist/assets/{yaml-CF4XlBJS.js → yaml-Cv5epQ4-.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +11 -3
  78. package/scripts/agent-exclusions.mjs +10 -0
  79. package/scripts/agent-review.mjs +94 -0
  80. package/scripts/build-diff-data.mjs +235 -50
  81. package/scripts/cli-args.mjs +36 -9
  82. package/scripts/coding-agents.mjs +206 -79
  83. package/scripts/generate-summaries.mjs +864 -425
  84. package/scripts/local-target.mjs +106 -0
  85. package/scripts/present.mjs +205 -72
  86. package/scripts/review-chat-context.mjs +510 -0
  87. package/scripts/review-chat-controller.mjs +568 -0
  88. package/scripts/review-chat-provider.mjs +352 -0
  89. package/scripts/review-chat.mjs +8 -0
  90. package/scripts/serve-built.mjs +206 -24
  91. package/scripts/summary-path.mjs +32 -11
  92. package/dist/assets/index-C2p_1iAk.css +0 -2
  93. package/dist/assets/index-f9ekN94b.js +0 -1568
@@ -0,0 +1,352 @@
1
+ import { spawn } from 'node:child_process';
2
+ import {
3
+ mkdtempSync,
4
+ rmSync,
5
+ writeFileSync,
6
+ } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import {
10
+ agentCommand,
11
+ parseAgentResponse,
12
+ } from './coding-agents.mjs';
13
+
14
+ const maximumProviderOutputBytes = 1024 * 1024;
15
+ const forcedChildKillDelayMs = 250;
16
+ const noChange = () => {};
17
+
18
+ function removeTemporary(path) {
19
+ rmSync(path, { force: true, recursive: true });
20
+ }
21
+
22
+ function cancelledError() {
23
+ return new Error('The chat request was cancelled.');
24
+ }
25
+
26
+ function spawnProcess(invocation) {
27
+ try {
28
+ return {
29
+ child: spawn(invocation.command, invocation.args, {
30
+ cwd: invocation.cwd,
31
+ env: invocation.env,
32
+ stdio: ['pipe', 'pipe', 'pipe'],
33
+ }),
34
+ };
35
+ } catch (error) {
36
+ return { error };
37
+ }
38
+ }
39
+
40
+ function childOutput(chunks) {
41
+ return Buffer.concat(chunks).toString('utf8');
42
+ }
43
+
44
+ function childFailure(agent, status, signal, stdout, stderr) {
45
+ const detail = stderr.trim() || stdout.trim();
46
+ const suffix = detail ? `: ${detail.slice(-600)}` : '';
47
+ return new Error(`${agent} exited with status ${status ?? signal}${suffix}`);
48
+ }
49
+
50
+ function aborted(signal) {
51
+ return Boolean(signal?.aborted);
52
+ }
53
+
54
+ function successfulExit(status, signal) {
55
+ return status === 0 && !signal;
56
+ }
57
+
58
+ function closeError(execution, status, signal) {
59
+ if (aborted(execution.signal)) return cancelledError();
60
+ if (successfulExit(status, signal)) return undefined;
61
+ return childFailure(
62
+ execution.agent,
63
+ status,
64
+ signal,
65
+ childOutput(execution.stdout),
66
+ childOutput(execution.stderr),
67
+ );
68
+ }
69
+
70
+ function providerAgent(options) {
71
+ const agent = options?.agent;
72
+ if (!agent) throw new Error('Chat provider needs a coding agent');
73
+ return agent;
74
+ }
75
+
76
+ function providerBinary(options, agent) {
77
+ return options.binary || agent;
78
+ }
79
+
80
+ function providerEnvironment(options) {
81
+ return options.env || process.env;
82
+ }
83
+
84
+ function providerOptions(options) {
85
+ const agent = providerAgent(options);
86
+ return {
87
+ agent,
88
+ binary: providerBinary(options, agent),
89
+ model: options.model,
90
+ reasoning: options.reasoning,
91
+ accessMode: options.accessMode,
92
+ env: providerEnvironment(options),
93
+ };
94
+ }
95
+
96
+ function temporaryRequest() {
97
+ const temporary = mkdtempSync(join(tmpdir(), 'diffsplain-review-chat-'));
98
+ return {
99
+ temporary,
100
+ inputPath: join(temporary, 'input.json'),
101
+ schemaPath: join(temporary, 'schema.json'),
102
+ };
103
+ }
104
+
105
+ function writeRequest(paths, request) {
106
+ writeFileSync(paths.inputPath, JSON.stringify(request));
107
+ writeFileSync(paths.schemaPath, JSON.stringify(request.responseSchema));
108
+ }
109
+
110
+ function providerInvocation(options, request, paths) {
111
+ return agentCommand({
112
+ agent: options.agent,
113
+ binary: options.binary,
114
+ model: options.model,
115
+ reasoning: options.reasoning,
116
+ prompt: request.prompt,
117
+ schema: request.responseSchema,
118
+ schemaPath: paths.schemaPath,
119
+ inputPath: paths.inputPath,
120
+ accessMode: options.accessMode,
121
+ env: options.env,
122
+ });
123
+ }
124
+
125
+ function preparedProviderRequest(options, request) {
126
+ const paths = temporaryRequest();
127
+ try {
128
+ writeRequest(paths, request);
129
+ return { temporary: paths.temporary, invocation: providerInvocation(options, request, paths) };
130
+ } catch (error) {
131
+ removeTemporary(paths.temporary);
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ class ChildExecution {
137
+ constructor({ invocation, input, agent, signal, temporary }) {
138
+ this.invocation = invocation;
139
+ this.input = input;
140
+ this.agent = agent;
141
+ this.signal = signal;
142
+ this.temporary = temporary;
143
+ this.child = undefined;
144
+ this.settled = false;
145
+ this.closed = false;
146
+ this.forceKillTimer = undefined;
147
+ this.stdout = [];
148
+ this.stderr = [];
149
+ this.outputBytes = 0;
150
+ this.promise = new Promise((resolvePromise, rejectPromise) => {
151
+ this.resolvePromise = resolvePromise;
152
+ this.rejectPromise = rejectPromise;
153
+ });
154
+ this.finished = new Promise((resolveFinished) => {
155
+ this.resolveFinished = resolveFinished;
156
+ });
157
+ }
158
+
159
+ open() {
160
+ const result = spawnProcess(this.invocation);
161
+ if (result.error) {
162
+ this.reject(result.error);
163
+ this.finishClose();
164
+ }
165
+ else this.openChild(result.child);
166
+ return {
167
+ promise: this.promise,
168
+ finished: this.finished,
169
+ cancel: this.cancel.bind(this),
170
+ };
171
+ }
172
+
173
+ openChild(child) {
174
+ this.child = child;
175
+ this.listen();
176
+ this.writeInput();
177
+ }
178
+
179
+ listen() {
180
+ this.child.stdout.on('data', this.receiveStdout.bind(this));
181
+ this.child.stderr.on('data', this.receiveStderr.bind(this));
182
+ this.child.stdin.once('error', this.stdinError.bind(this));
183
+ this.child.once('error', this.childError.bind(this));
184
+ this.child.once('close', this.closeChild.bind(this));
185
+ this.signal?.addEventListener('abort', this.terminate.bind(this), { once: true });
186
+ }
187
+
188
+ writeInput() {
189
+ if (this.invocation.input === 'stdin') this.child.stdin.end(this.input);
190
+ else this.child.stdin.end();
191
+ }
192
+
193
+ receiveStdout(chunk) {
194
+ this.receiveOutput(this.stdout, chunk);
195
+ }
196
+
197
+ receiveStderr(chunk) {
198
+ this.receiveOutput(this.stderr, chunk);
199
+ }
200
+
201
+ receiveOutput(chunks, chunk) {
202
+ const nextBytes = this.outputBytes + chunk.length;
203
+ if (nextBytes <= maximumProviderOutputBytes) {
204
+ this.outputBytes = nextBytes;
205
+ chunks.push(chunk);
206
+ return;
207
+ }
208
+ this.terminate();
209
+ this.reject(new Error(`${this.agent} returned too much chat output`));
210
+ }
211
+
212
+ stdinError(error) {
213
+ if (error.code !== 'EPIPE') this.reject(error);
214
+ }
215
+
216
+ childError(error) {
217
+ this.reject(error);
218
+ if (!this.running()) this.finishClose();
219
+ }
220
+
221
+ running() {
222
+ return Number.isInteger(this.child?.pid) && this.child.exitCode === null;
223
+ }
224
+
225
+ sendSignal(signal) {
226
+ try {
227
+ this.child.kill(signal);
228
+ return true;
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ killChild() {
235
+ if (!this.running()) return;
236
+ this.sendSignal('SIGKILL');
237
+ }
238
+
239
+ scheduleForceKill() {
240
+ if (this.forceKillTimer) return;
241
+ this.forceKillTimer = setTimeout(() => {
242
+ this.forceKillTimer = undefined;
243
+ this.killChild();
244
+ }, forcedChildKillDelayMs);
245
+ this.forceKillTimer.unref();
246
+ }
247
+
248
+ terminate() {
249
+ if (!this.running()) return;
250
+ if (!this.sendSignal('SIGTERM')) return;
251
+ this.scheduleForceKill();
252
+ }
253
+
254
+ clearForceKill() {
255
+ if (!this.forceKillTimer) return;
256
+ clearTimeout(this.forceKillTimer);
257
+ this.forceKillTimer = undefined;
258
+ }
259
+
260
+ settle(callback, value) {
261
+ if (this.settled) return;
262
+ this.settled = true;
263
+ callback(value);
264
+ }
265
+
266
+ finishClose() {
267
+ if (this.closed) return;
268
+ this.closed = true;
269
+ this.clearForceKill();
270
+ removeTemporary(this.temporary);
271
+ this.resolveFinished();
272
+ }
273
+
274
+ resolve(value) {
275
+ this.settle(this.resolvePromise, value);
276
+ }
277
+
278
+ reject(error) {
279
+ this.settle(this.rejectPromise, error);
280
+ }
281
+
282
+ parseOutput() {
283
+ try {
284
+ this.resolve(parseAgentResponse(this.agent, childOutput(this.stdout)));
285
+ } catch (error) {
286
+ this.reject(error);
287
+ }
288
+ }
289
+
290
+ closeChild(status, signal) {
291
+ this.clearForceKill();
292
+ if (!this.settled) {
293
+ const error = closeError(this, status, signal);
294
+ if (error) this.reject(error);
295
+ else this.parseOutput();
296
+ }
297
+ this.finishClose();
298
+ }
299
+
300
+ cancel() {
301
+ if (this.settled) return;
302
+ this.terminate();
303
+ this.reject(cancelledError());
304
+ }
305
+ }
306
+
307
+ function providerChild(options) {
308
+ return new ChildExecution(options).open();
309
+ }
310
+
311
+ function trackExecution(executions, execution) {
312
+ executions.add(execution);
313
+ execution.finished.finally(() => executions.delete(execution)).catch(noChange);
314
+ return execution;
315
+ }
316
+
317
+ function runRequest(options, executions, request) {
318
+ const prepared = preparedProviderRequest(options, request);
319
+ const controller = new AbortController();
320
+ const execution = providerChild({
321
+ invocation: prepared.invocation,
322
+ input: JSON.stringify(request),
323
+ agent: options.agent,
324
+ signal: controller.signal,
325
+ temporary: prepared.temporary,
326
+ });
327
+ const tracked = trackExecution(executions, execution);
328
+ return {
329
+ promise: tracked.promise,
330
+ cancel() {
331
+ controller.abort();
332
+ tracked.cancel();
333
+ },
334
+ };
335
+ }
336
+
337
+ function closeExecutions(executions) {
338
+ for (const execution of executions) execution.cancel();
339
+ }
340
+
341
+ export function createCodingAgentChatProvider(options = {}) {
342
+ const configured = providerOptions(options);
343
+ const executions = new Set();
344
+ return {
345
+ run(request) {
346
+ return runRequest(configured, executions, request);
347
+ },
348
+ close() {
349
+ closeExecutions(executions);
350
+ },
351
+ };
352
+ }
@@ -0,0 +1,8 @@
1
+ import { ReviewChatController } from './review-chat-controller.mjs';
2
+
3
+ export { ReviewChatError } from './review-chat-context.mjs';
4
+ export { createCodingAgentChatProvider } from './review-chat-provider.mjs';
5
+
6
+ export function createReviewChat(options) {
7
+ return new ReviewChatController(options).api();
8
+ }
@@ -6,6 +6,11 @@ import { readFile, stat } from 'node:fs/promises';
6
6
  import { createServer } from 'node:http';
7
7
  import { dirname, extname, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import {
10
+ ReviewChatError,
11
+ createCodingAgentChatProvider,
12
+ createReviewChat,
13
+ } from './review-chat.mjs';
9
14
 
10
15
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
16
  const clientRoot = resolve(root, 'dist');
@@ -27,6 +32,13 @@ const portValue = option('--port', '2299');
27
32
  const host = option('--host', 'localhost').replace(/^\[|\]$/g, '');
28
33
  const access = option('--access', randomBytes(32).toString('base64url'));
29
34
  const previousAccess = option('--previous-access', '');
35
+ const chatSnapshot = resolve(option('--chat-snapshot', output));
36
+ const chatAgent = option('--chat-agent');
37
+ const chatBinary = option('--chat-binary', chatAgent);
38
+ const chatModel = option('--chat-model');
39
+ const chatReasoning = option('--chat-reasoning');
40
+ const chatAccessMode = option('--chat-access-mode', 'snapshot-only');
41
+ const chatAccessRoot = option('--chat-access-root');
30
42
  if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
31
43
  throw new Error('--port must be a number from 0 to 65535');
32
44
  }
@@ -36,6 +48,12 @@ if (!/^[A-Za-z0-9_-]{32,}$/.test(access)) {
36
48
  if (previousAccess && !/^[A-Za-z0-9_-]{32,}$/.test(previousAccess)) {
37
49
  throw new Error('--previous-access must be an unguessable URL-safe value');
38
50
  }
51
+ if (!['checkout-read-only', 'snapshot-only'].includes(chatAccessMode)) {
52
+ throw new Error('--chat-access-mode must be checkout-read-only or snapshot-only');
53
+ }
54
+ if (chatAccessMode === 'checkout-read-only' && !chatAccessRoot) {
55
+ throw new Error('--chat-access-root is required for checkout chat access');
56
+ }
39
57
  const incrementPort =
40
58
  rawArgs.includes('--increment-port') || !rawArgs.includes('--port');
41
59
 
@@ -270,6 +288,60 @@ function jsonResponse(value, status = 200) {
270
288
  });
271
289
  }
272
290
 
291
+ const chatBodyLimitBytes = 64 * 1024;
292
+
293
+ function chatJsonContentType(value) {
294
+ return typeof value === 'string' &&
295
+ /^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(value);
296
+ }
297
+
298
+ function chatRequestBody(request) {
299
+ if (!chatJsonContentType(request.headers['content-type'])) {
300
+ throw new ReviewChatError('Chat commands need application/json.', 415);
301
+ }
302
+ const contentLength = Number(request.headers['content-length']);
303
+ if (Number.isFinite(contentLength) && contentLength > chatBodyLimitBytes) {
304
+ throw new ReviewChatError('Chat command body is too large.', 413);
305
+ }
306
+ return new Promise((resolvePromise, reject) => {
307
+ const chunks = [];
308
+ let bytes = 0;
309
+ let settled = false;
310
+ const fail = (error) => {
311
+ if (settled) return;
312
+ settled = true;
313
+ request.resume();
314
+ reject(error);
315
+ };
316
+ request.on('data', (chunk) => {
317
+ bytes += chunk.length;
318
+ if (bytes > chatBodyLimitBytes) {
319
+ fail(new ReviewChatError('Chat command body is too large.', 413));
320
+ return;
321
+ }
322
+ chunks.push(chunk);
323
+ });
324
+ request.once('error', fail);
325
+ request.once('end', () => {
326
+ if (settled) return;
327
+ settled = true;
328
+ try {
329
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
330
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
331
+ throw new ReviewChatError('Chat commands must be JSON objects.');
332
+ }
333
+ resolvePromise(value);
334
+ } catch (error) {
335
+ reject(
336
+ error instanceof ReviewChatError
337
+ ? error
338
+ : new ReviewChatError('Chat command JSON is not valid.'),
339
+ );
340
+ }
341
+ });
342
+ });
343
+ }
344
+
273
345
  async function send(nodeResponse, webResponse) {
274
346
  nodeResponse.writeHead(
275
347
  webResponse.status,
@@ -287,6 +359,32 @@ let selectedPort = Number(portValue);
287
359
  let readyState;
288
360
  let closing = false;
289
361
 
362
+ function broadcastEvent(name, data = {}) {
363
+ const payload = JSON.stringify(data);
364
+ for (const client of eventClients) {
365
+ if (!client.destroyed) client.write(`event: ${name}\ndata: ${payload}\n\n`);
366
+ }
367
+ }
368
+
369
+ const chatProviderAccess = chatAccessMode === 'checkout-read-only'
370
+ ? { mode: chatAccessMode, root: resolve(chatAccessRoot) }
371
+ : { mode: chatAccessMode, reason: 'target-mismatch' };
372
+ const chatProvider = chatAgent
373
+ ? createCodingAgentChatProvider({
374
+ agent: chatAgent,
375
+ binary: chatBinary,
376
+ model: chatModel,
377
+ reasoning: chatReasoning,
378
+ accessMode: chatProviderAccess,
379
+ })
380
+ : undefined;
381
+ const reviewChat = createReviewChat({
382
+ snapshotPath: chatSnapshot,
383
+ provider: chatProvider,
384
+ accessMode: chatProviderAccess,
385
+ onChange: () => broadcastEvent('chat'),
386
+ });
387
+
290
388
  function requestAuthorityError(request) {
291
389
  const localAddress = request.socket.localAddress || '';
292
390
  if (!validHost(request.headers.host, localAddress)) {
@@ -298,14 +396,22 @@ function requestAuthorityError(request) {
298
396
  return undefined;
299
397
  }
300
398
 
399
+ function allowedMethods(url) {
400
+ return url.pathname === '/api/chat' ? ['GET', 'POST'] : ['GET', 'HEAD'];
401
+ }
402
+
403
+ function methodError(request, url) {
404
+ if (allowedMethods(url).includes(request.method)) return undefined;
405
+ return webResponse('Method not allowed', { status: 405 });
406
+ }
407
+
301
408
  function requestContext(request) {
302
409
  const url = parseRequestUrl(request);
303
410
  if (!url) return { error: clientError() };
304
411
  const authorityError = requestAuthorityError(request);
305
412
  if (authorityError) return { error: authorityError };
306
- if (!['GET', 'HEAD'].includes(request.method)) {
307
- return { error: webResponse('Method not allowed', { status: 405 }) };
308
- }
413
+ const error = methodError(request, url);
414
+ if (error) return { error };
309
415
  return { url };
310
416
  }
311
417
 
@@ -349,20 +455,98 @@ async function routeResponse(url) {
349
455
  return fetchAsset(url);
350
456
  }
351
457
 
458
+ function chatAccessError(url) {
459
+ if (validAccess(url.searchParams.get('access'))) return undefined;
460
+ return webResponse('Forbidden', { status: 403 });
461
+ }
462
+
463
+ function chatCommandError(error) {
464
+ if (error instanceof ReviewChatError) {
465
+ return { status: error.status, message: error.message };
466
+ }
467
+ return { status: 400, message: 'Chat command failed.' };
468
+ }
469
+
470
+ async function sendChatCommand(request, response) {
471
+ try {
472
+ const result = reviewChat.command(await chatRequestBody(request));
473
+ const status = result.accepted ? 202 : 200;
474
+ await send(response, jsonResponse(result.state, status));
475
+ } catch (error) {
476
+ const failure = chatCommandError(error);
477
+ await send(response, jsonResponse({ error: failure.message }, failure.status));
478
+ }
479
+ }
480
+
481
+ async function handleChatRequest(request, response, url) {
482
+ const accessError = chatAccessError(url);
483
+ if (accessError) {
484
+ await send(response, accessError);
485
+ return;
486
+ }
487
+ if (request.method === 'GET') {
488
+ await send(response, jsonResponse(reviewChat.getState()));
489
+ return;
490
+ }
491
+ await sendChatCommand(request, response);
492
+ }
493
+
494
+ async function handleEventsRequest(_request, response, url) {
495
+ await sendEvents(response, url);
496
+ }
497
+
498
+ function handlerFor(url) {
499
+ return {
500
+ '/events': handleEventsRequest,
501
+ '/api/chat': handleChatRequest,
502
+ }[url.pathname];
503
+ }
504
+
505
+ function throwForcedHandlerFailure(url) {
506
+ if (forcedHandlerFailure(url)) throw new Error('forced request handler failure');
507
+ }
508
+
509
+ async function handleRoutedRequest(request, response, url) {
510
+ const handler = handlerFor(url);
511
+ if (handler) {
512
+ await handler(request, response, url);
513
+ return;
514
+ }
515
+ await send(response, await routeResponse(url));
516
+ }
517
+
352
518
  async function handleRequest(request, response) {
353
519
  const context = requestContext(request);
354
520
  if (context.error) {
355
521
  await send(response, context.error);
356
522
  return;
357
523
  }
358
- if (forcedHandlerFailure(context.url)) {
359
- throw new Error('forced request handler failure');
360
- }
361
- if (context.url.pathname === '/events') {
362
- await sendEvents(response, context.url);
363
- return;
364
- }
365
- await send(response, await routeResponse(context.url));
524
+ throwForcedHandlerFailure(context.url);
525
+ await handleRoutedRequest(request, response, context.url);
526
+ }
527
+
528
+ function stopWatchers() {
529
+ unwatchFile(output);
530
+ if (chatSnapshot !== output) unwatchFile(chatSnapshot);
531
+ }
532
+
533
+ function closeEventClients() {
534
+ for (const client of eventClients) client.end();
535
+ eventClients.clear();
536
+ }
537
+
538
+ function closeServer() {
539
+ if (server.listening) server.close();
540
+ }
541
+
542
+ function close(exitCode = 0) {
543
+ if (closing) return;
544
+ closing = true;
545
+ process.exitCode = exitCode;
546
+ stopWatchers();
547
+ reviewChat.close();
548
+ closeEventClients();
549
+ closeServer();
366
550
  }
367
551
 
368
552
  async function containHandlerFailure(response) {
@@ -390,11 +574,19 @@ watchFile(output, { interval: 100 }, (current, previous) => {
390
574
  if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) {
391
575
  return;
392
576
  }
393
- for (const client of eventClients) {
394
- if (!client.destroyed) client.write('event: update\ndata: {}\n\n');
395
- }
577
+ reviewChat.refresh();
578
+ broadcastEvent('update');
396
579
  });
397
580
 
581
+ if (chatSnapshot !== output) {
582
+ watchFile(chatSnapshot, { interval: 100 }, (current, previous) => {
583
+ if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) {
584
+ return;
585
+ }
586
+ reviewChat.refresh();
587
+ });
588
+ }
589
+
398
590
  function urlFor(address, port) {
399
591
  const formattedAddress = address.includes(':') ? `[${address}]` : address;
400
592
  return `http://${formattedAddress}:${port}`;
@@ -454,15 +646,5 @@ server.on('error', (error) => {
454
646
 
455
647
  listen();
456
648
 
457
- function close(exitCode = 0) {
458
- if (closing) return;
459
- closing = true;
460
- process.exitCode = exitCode;
461
- unwatchFile(output);
462
- for (const client of eventClients) client.end();
463
- eventClients.clear();
464
- if (server.listening) server.close();
465
- }
466
-
467
649
  process.on('SIGINT', () => close());
468
650
  process.on('SIGTERM', () => close());