diffsplain 0.9.0 → 0.11.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 (88) hide show
  1. package/README.md +21 -4
  2. package/dist/assets/{angular-html-m_m91ZYB.js → angular-html-CjYwnGyV.js} +1 -1
  3. package/dist/assets/{angular-ts-CO5Z--Rb.js → angular-ts-DSPf2G4O.js} +1 -1
  4. package/dist/assets/{apl-ogYYgLUu.js → apl-B9ytghJx.js} +1 -1
  5. package/dist/assets/{astro-DAfY3cNR.js → astro-BsGV0yBF.js} +1 -1
  6. package/dist/assets/{blade-E6RzsLUV.js → blade-Cc2eCYlt.js} +1 -1
  7. package/dist/assets/{c-kKdxhtHF.js → c-CWKyCnUx.js} +1 -1
  8. package/dist/assets/{cobol-3oDDyFNf.js → cobol-DkamOI1E.js} +1 -1
  9. package/dist/assets/{coffee-dsiILuWe.js → coffee-DyaEXePe.js} +1 -1
  10. package/dist/assets/{cpp-DYyNFq__.js → cpp-16-d2Eyo.js} +1 -1
  11. package/dist/assets/{crystal-B7dVp9TF.js → crystal-CavPPRhQ.js} +1 -1
  12. package/dist/assets/{css-DlxJ0aex.js → css-BME_wHJq.js} +1 -1
  13. package/dist/assets/{edge-BjEVphkC.js → edge-DeVi2hvK.js} +1 -1
  14. package/dist/assets/{elixir-0xTOtMcM.js → elixir-CCWgH_vc.js} +1 -1
  15. package/dist/assets/{elm-iQk_Wrxq.js → elm-CZ570J7x.js} +1 -1
  16. package/dist/assets/{erb-BO74ztiE.js → erb-CDPT5bea.js} +1 -1
  17. package/dist/assets/{git-rebase-BN667QRY.js → git-rebase-Dq5Z37DJ.js} +1 -1
  18. package/dist/assets/{glimmer-js-DTGUIHeS.js → glimmer-js-BoPVZK8p.js} +1 -1
  19. package/dist/assets/{glimmer-ts-BU3oQn2d.js → glimmer-ts-DazEWfg1.js} +1 -1
  20. package/dist/assets/{glsl-KLiqLJi2.js → glsl-DCUjYCZY.js} +1 -1
  21. package/dist/assets/{graphql-D_idcsoJ.js → graphql-DkCeSxy7.js} +1 -1
  22. package/dist/assets/{hack-BXlsgPIr.js → hack-tiY3l-W-.js} +1 -1
  23. package/dist/assets/{haml-fP0aUg4-.js → haml-DyQZrJg_.js} +1 -1
  24. package/dist/assets/{handlebars-C1ZEaot-.js → handlebars-Bvu5Rr0t.js} +1 -1
  25. package/dist/assets/{html-D04qIuup.js → html-C60VFgKW.js} +1 -1
  26. package/dist/assets/{html-derivative-C6ai-9QL.js → html-derivative-DB69gpnL.js} +1 -1
  27. package/dist/assets/{http-C7bz3ZvQ.js → http-DmS8hddd.js} +1 -1
  28. package/dist/assets/{hurl-jL6B6Ahr.js → hurl-Cdp6lmUx.js} +1 -1
  29. package/dist/assets/{index-BPaJGi-m.js → index-CUiLG0r9.js} +4 -4
  30. package/dist/assets/{index-VAyBpb3u.css → index-YnLIxcTm.css} +1 -1
  31. package/dist/assets/{java-BVzvLCON.js → java-CkOnwoVQ.js} +1 -1
  32. package/dist/assets/{javascript-UDbZHxCI.js → javascript-D2tF_Ylj.js} +1 -1
  33. package/dist/assets/{jinja-DB-QKo2A.js → jinja-CdWpLcff.js} +1 -1
  34. package/dist/assets/{jison-B1ZbR2FF.js → jison-BII58uuR.js} +1 -1
  35. package/dist/assets/{json-DV4WwJzT.js → json-BKlPRLrU.js} +1 -1
  36. package/dist/assets/{jsx-DS6_VTvW.js → jsx-Ba5UouxN.js} +1 -1
  37. package/dist/assets/{julia-C4tevET0.js → julia-CwdQF8Ff.js} +1 -1
  38. package/dist/assets/{just-CWMjefzB.js → just-Be3sr1-h.js} +1 -1
  39. package/dist/assets/{latex-tyEFf_Af.js → latex-CDF6t93N.js} +1 -1
  40. package/dist/assets/{liquid-C3d1X1rL.js → liquid-BE3LVHkw.js} +1 -1
  41. package/dist/assets/{lua-sXvrzLQY.js → lua-D_W8Ux9S.js} +1 -1
  42. package/dist/assets/{marko-CQweucif.js → marko-BeUfMD-U.js} +1 -1
  43. package/dist/assets/{mdc-TrxLWF7s.js → mdc-C8tRJlVU.js} +1 -1
  44. package/dist/assets/{nginx-ChE2nSNJ.js → nginx-CLPimMM6.js} +1 -1
  45. package/dist/assets/{nim-BY1CTRfx.js → nim-ikQhXKQf.js} +1 -1
  46. package/dist/assets/{perl-Cvj2PQQH.js → perl-C27cToFa.js} +1 -1
  47. package/dist/assets/{php-CGmHp8HW.js → php-Btdg-RP3.js} +1 -1
  48. package/dist/assets/{pug-DM4S0bRr.js → pug-Bk73ERXL.js} +1 -1
  49. package/dist/assets/{qml-DI89Al9K.js → qml-DhY7FDIl.js} +1 -1
  50. package/dist/assets/{r-Bs5_4yYh.js → r-CEwua7nj.js} +1 -1
  51. package/dist/assets/{razor-DimzayhZ.js → razor-sPO7Sh9d.js} +1 -1
  52. package/dist/assets/{regexp-BLACz0cx.js → regexp-D9DLFGXE.js} +1 -1
  53. package/dist/assets/{rst-DcWzxQh2.js → rst-YhauCnId.js} +1 -1
  54. package/dist/assets/{ruby-Dlat5E8d.js → ruby-DpMkRaoz.js} +1 -1
  55. package/dist/assets/{sas-CCDmMaBY.js → sas-0KdmKGRO.js} +1 -1
  56. package/dist/assets/{scss-BPiU2nu6.js → scss-CohvHWvq.js} +1 -1
  57. package/dist/assets/{shellscript-CKinVNrN.js → shellscript-C93Q8fLt.js} +1 -1
  58. package/dist/assets/{shellsession-Cea8Pu5d.js → shellsession-YCh2AQXu.js} +1 -1
  59. package/dist/assets/{soy--BRAPY4-.js → soy-CWVYdXUH.js} +1 -1
  60. package/dist/assets/{sql-DjKiCyWK.js → sql-CJNtax5I.js} +1 -1
  61. package/dist/assets/{stata-CDvo3J6y.js → stata-C7v41XAM.js} +1 -1
  62. package/dist/assets/{surrealql-C7b7E5sh.js → surrealql-CRUSOxDf.js} +1 -1
  63. package/dist/assets/{svelte-DO0tKtiK.js → svelte-Z0harRr1.js} +1 -1
  64. package/dist/assets/{templ-DhhmR_rv.js → templ-BplDouJX.js} +1 -1
  65. package/dist/assets/{tex-Cn_V-Gg4.js → tex-MsB6StN5.js} +1 -1
  66. package/dist/assets/{ts-tags-B6c-FTmA.js → ts-tags-BWx8aq5t.js} +1 -1
  67. package/dist/assets/{tsx-Cvtt2sJ-.js → tsx-DBT1gmNl.js} +1 -1
  68. package/dist/assets/{twig-B7ZbCUmB.js → twig-jz67rR3C.js} +1 -1
  69. package/dist/assets/{typescript-i3C6um2B.js → typescript-CzwGN36B.js} +1 -1
  70. package/dist/assets/{vue-html-D362KpK3.js → vue-html-Dhrmu7sO.js} +1 -1
  71. package/dist/assets/{vue-vine-D34cBbwO.js → vue-vine-BfQGXZs6.js} +1 -1
  72. package/dist/assets/{vue-DyPX0Vum.js → vue-xsrSiVMC.js} +1 -1
  73. package/dist/assets/{xml-DJEk7u4n.js → xml-W7cP4qeN.js} +1 -1
  74. package/dist/assets/{xsl-CoTtf90f.js → xsl-BlXf8qm_.js} +1 -1
  75. package/dist/assets/{yaml-Cv5epQ4-.js → yaml-BtNwxUT9.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +4 -2
  78. package/scripts/agent-config.mjs +184 -0
  79. package/scripts/agent-usage.mjs +141 -0
  80. package/scripts/build-diff-data.mjs +32 -12
  81. package/scripts/cli-args.mjs +36 -2
  82. package/scripts/coding-agents.mjs +265 -13
  83. package/scripts/generate-summaries.mjs +72 -13
  84. package/scripts/present.mjs +156 -58
  85. package/scripts/review-chat-context.mjs +6 -4
  86. package/scripts/review-chat-controller.mjs +142 -17
  87. package/scripts/review-chat-provider.mjs +40 -7
  88. package/scripts/serve-built.mjs +139 -7
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { performance } from 'node:perf_hooks';
2
3
  import {
3
4
  ReviewChatError,
4
5
  chatInputLimit,
@@ -21,6 +22,45 @@ import {
21
22
 
22
23
  const noChange = () => {};
23
24
  const snapshotOnly = { mode: 'snapshot-only' };
25
+ const defaultRunTimeoutMs = 120_000;
26
+ const maximumProgressIntervalMs = 30_000;
27
+
28
+ function runTimeout(options) {
29
+ const timeoutMs = options.runTimeoutMs ?? defaultRunTimeoutMs;
30
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
31
+ throw new Error('Review chat run timeout must be a positive integer');
32
+ }
33
+ return timeoutMs;
34
+ }
35
+
36
+ function progressInterval(timeoutMs) {
37
+ return Math.min(maximumProgressIntervalMs, Math.max(10, Math.floor(timeoutMs / 2)));
38
+ }
39
+
40
+ function timeoutError(timeoutMs) {
41
+ const duration = timeoutMs >= 1_000
42
+ ? `${Math.round(timeoutMs / 1_000)} seconds`
43
+ : `${timeoutMs}ms`;
44
+ return new Error(`The chat provider timed out after ${duration}.`);
45
+ }
46
+
47
+ function lifecycleError(error) {
48
+ return typeof error?.lifecycleMessage === 'string'
49
+ ? error.lifecycleMessage
50
+ : publicError(error);
51
+ }
52
+
53
+ function compactedMessages(run, answer) {
54
+ return [{ role: 'compacted', answer }, ...run.preserved];
55
+ }
56
+
57
+ function oversizedCompaction(thread, run, answer, inputLimitBytes) {
58
+ if (run.kind !== 'compact') return false;
59
+ return inputBytes(
60
+ compactedMessages(run, answer),
61
+ thread.pendingQuestion,
62
+ ) > inputLimitBytes;
63
+ }
24
64
  function snapshotRead(path) {
25
65
  try {
26
66
  return { value: JSON.parse(readFileSync(path, 'utf8')) };
@@ -90,9 +130,9 @@ function validatedAnswer(value, paths) {
90
130
  }
91
131
  }
92
132
 
93
- function executionResult(provider, input) {
133
+ function executionResult(provider, input, context) {
94
134
  try {
95
- return { execution: providerExecution(provider, input) };
135
+ return { execution: providerExecution(provider, input, context) };
96
136
  } catch (error) {
97
137
  return { error };
98
138
  }
@@ -133,6 +173,10 @@ export class ReviewChatController {
133
173
  this.snapshotPath = chatSnapshotPath(options);
134
174
  this.provider = options.provider;
135
175
  this.onChange = typeof options.onChange === 'function' ? options.onChange : noChange;
176
+ this.onLifecycle = typeof options.onLifecycle === 'function'
177
+ ? options.onLifecycle
178
+ : noChange;
179
+ this.runTimeoutMs = runTimeout(options);
136
180
  this.inputLimitBytes = chatInputLimit(options);
137
181
  this.accessMode = options.accessMode || snapshotOnly;
138
182
  this.threads = new Map();
@@ -237,6 +281,53 @@ export class ReviewChatController {
237
281
  thread.activeRunId = undefined;
238
282
  }
239
283
 
284
+ elapsed(run) {
285
+ return Math.max(0, Math.round(performance.now() - run.startedAt));
286
+ }
287
+
288
+ emitLifecycleSafely(type, run, details = {}) {
289
+ try {
290
+ this.onLifecycle({
291
+ type,
292
+ kind: run.kind,
293
+ runId: run.id,
294
+ elapsedMs: this.elapsed(run),
295
+ terminal: details.terminal === true,
296
+ ...(details.error ? { error: lifecycleError(details.error) } : {}),
297
+ });
298
+ } catch {}
299
+ }
300
+
301
+ clearSupervision(run) {
302
+ if (run.timeoutTimer) clearTimeout(run.timeoutTimer);
303
+ if (run.progressTimer) clearInterval(run.progressTimer);
304
+ run.timeoutTimer = undefined;
305
+ run.progressTimer = undefined;
306
+ }
307
+
308
+ finishLifecycle(run, type, error) {
309
+ if (run.lifecycle !== 'active') return false;
310
+ run.lifecycle = type;
311
+ this.clearSupervision(run);
312
+ this.emitLifecycleSafely(type, run, { terminal: true, error });
313
+ return true;
314
+ }
315
+
316
+ supervise(thread, run) {
317
+ run.lifecycle = 'active';
318
+ run.startedAt = performance.now();
319
+ this.emitLifecycleSafely('start', run);
320
+ run.progressTimer = setInterval(() => {
321
+ if (run.lifecycle === 'active') this.emitLifecycleSafely('progress', run);
322
+ }, progressInterval(this.runTimeoutMs));
323
+ run.progressTimer.unref?.();
324
+ run.timeoutTimer = setTimeout(
325
+ () => this.timeoutRun(thread, run),
326
+ this.runTimeoutMs,
327
+ );
328
+ run.timeoutTimer.unref?.();
329
+ }
330
+
240
331
  markFailure(thread, run, error) {
241
332
  this.clearRun(thread);
242
333
  thread.error = publicError(error);
@@ -244,7 +335,9 @@ export class ReviewChatController {
244
335
  }
245
336
 
246
337
  markUnavailableRun(thread, run) {
247
- this.markFailure(thread, run, new Error('The current review snapshot is not available.'));
338
+ const error = new Error('The current review snapshot is not available.');
339
+ if (!this.finishLifecycle(run, 'failure', error)) return;
340
+ this.markFailure(thread, run, error);
248
341
  this.refresh({ notify: false });
249
342
  this.notify();
250
343
  }
@@ -266,6 +359,7 @@ export class ReviewChatController {
266
359
 
267
360
  finishFailure(thread, run, error) {
268
361
  if (this.fenceChangedReview(thread, run)) return;
362
+ if (!this.finishLifecycle(run, 'failure', error)) return;
269
363
  this.markFailure(thread, run, error);
270
364
  this.notify();
271
365
  }
@@ -281,15 +375,7 @@ export class ReviewChatController {
281
375
  }
282
376
 
283
377
  completeCompaction(thread, run, answer) {
284
- const messages = [{ role: 'compacted', answer }, ...run.preserved];
285
- if (inputBytes(messages, thread.pendingQuestion) > this.inputLimitBytes) {
286
- this.finishFailure(
287
- thread,
288
- run,
289
- new Error('The compacted chat history still exceeds the input limit.'),
290
- );
291
- return;
292
- }
378
+ const messages = compactedMessages(run, answer);
293
379
  this.clearRun(thread);
294
380
  thread.messages = messages;
295
381
  thread.needsCompaction = false;
@@ -309,6 +395,20 @@ export class ReviewChatController {
309
395
  this.finishFailure(thread, run, result.error);
310
396
  return;
311
397
  }
398
+ if (oversizedCompaction(
399
+ thread,
400
+ run,
401
+ result.answer,
402
+ this.inputLimitBytes,
403
+ )) {
404
+ this.finishFailure(
405
+ thread,
406
+ run,
407
+ new Error('The compacted chat history still exceeds the input limit.'),
408
+ );
409
+ return;
410
+ }
411
+ if (!this.finishLifecycle(run, 'complete')) return;
312
412
  this.completeRun(thread, run, result.answer);
313
413
  }
314
414
 
@@ -323,7 +423,10 @@ export class ReviewChatController {
323
423
  thread.status = run.kind === 'compact' ? 'compacting' : 'running';
324
424
  thread.error = undefined;
325
425
  this.notify();
326
- const result = executionResult(this.provider, input);
426
+ this.supervise(thread, run);
427
+ const result = executionResult(this.provider, input, {
428
+ reviewFingerprint: run.fingerprint,
429
+ });
327
430
  if (result.error) {
328
431
  this.finishFailure(thread, run, result.error);
329
432
  return;
@@ -401,13 +504,35 @@ export class ReviewChatController {
401
504
  }
402
505
  }
403
506
 
507
+ timeoutRun(thread, run) {
508
+ if (!this.currentRun(thread, run)) return;
509
+ const error = timeoutError(this.runTimeoutMs);
510
+ if (!this.finishLifecycle(run, 'timeout', error)) return;
511
+ this.clearRun(thread);
512
+ this.cancelProvider(run);
513
+ thread.error = publicError(error);
514
+ Object.assign(thread, runState(run));
515
+ this.notify();
516
+ }
517
+
518
+ prepareCancellation(thread, run) {
519
+ if (!this.finishLifecycle(run, 'cancel')) return false;
520
+ this.clearRun(thread);
521
+ this.cancelProvider(run);
522
+ return true;
523
+ }
524
+
525
+ restoreCancelledState(thread, run, stale) {
526
+ if (stale) return;
527
+ Object.assign(thread, cancelledState(run));
528
+ thread.error = undefined;
529
+ }
530
+
404
531
  cancelRun(thread, { stale = false } = {}) {
405
532
  const run = thread.run;
406
533
  if (!run) return false;
407
- this.clearRun(thread);
408
- this.cancelProvider(run);
409
- if (!stale) Object.assign(thread, cancelledState(run));
410
- if (!stale) thread.error = undefined;
534
+ if (!this.prepareCancellation(thread, run)) return false;
535
+ this.restoreCancelledState(thread, run, stale);
411
536
  return true;
412
537
  }
413
538
 
@@ -9,6 +9,7 @@ import { join } from 'node:path';
9
9
  import {
10
10
  agentCommand,
11
11
  parseAgentResponse,
12
+ parseAgentUsage,
12
13
  } from './coding-agents.mjs';
13
14
 
14
15
  const maximumProviderOutputBytes = 1024 * 1024;
@@ -41,10 +42,21 @@ function childOutput(chunks) {
41
42
  return Buffer.concat(chunks).toString('utf8');
42
43
  }
43
44
 
44
- function childFailure(agent, status, signal, stdout, stderr) {
45
+ function childStatus(status, signal) {
46
+ return status ?? signal;
47
+ }
48
+
49
+ function childDiagnosticSuffix(stdout, stderr) {
45
50
  const detail = stderr.trim() || stdout.trim();
46
- const suffix = detail ? `: ${detail.slice(-600)}` : '';
47
- return new Error(`${agent} exited with status ${status ?? signal}${suffix}`);
51
+ return detail ? `: ${detail.slice(-600)}` : '';
52
+ }
53
+
54
+ function childFailure(agent, status, signal, stdout, stderr) {
55
+ const observedStatus = childStatus(status, signal);
56
+ const suffix = childDiagnosticSuffix(stdout, stderr);
57
+ const error = new Error(`${agent} exited with status ${observedStatus}${suffix}`);
58
+ error.lifecycleMessage = `${agent} exited with status ${observedStatus}`;
59
+ return error;
48
60
  }
49
61
 
50
62
  function aborted(signal) {
@@ -88,8 +100,10 @@ function providerOptions(options) {
88
100
  binary: providerBinary(options, agent),
89
101
  model: options.model,
90
102
  reasoning: options.reasoning,
103
+ fast: options.fast === true,
91
104
  accessMode: options.accessMode,
92
105
  env: providerEnvironment(options),
106
+ onUsage: typeof options.onUsage === 'function' ? options.onUsage : noChange,
93
107
  };
94
108
  }
95
109
 
@@ -113,6 +127,7 @@ function providerInvocation(options, request, paths) {
113
127
  binary: options.binary,
114
128
  model: options.model,
115
129
  reasoning: options.reasoning,
130
+ fast: options.fast,
116
131
  prompt: request.prompt,
117
132
  schema: request.responseSchema,
118
133
  schemaPath: paths.schemaPath,
@@ -134,12 +149,15 @@ function preparedProviderRequest(options, request) {
134
149
  }
135
150
 
136
151
  class ChildExecution {
137
- constructor({ invocation, input, agent, signal, temporary }) {
152
+ constructor({ invocation, input, agent, signal, temporary, onUsage, usageContext }) {
138
153
  this.invocation = invocation;
139
154
  this.input = input;
140
155
  this.agent = agent;
141
156
  this.signal = signal;
142
157
  this.temporary = temporary;
158
+ this.onUsage = onUsage;
159
+ this.usageContext = usageContext;
160
+ this.usageReported = false;
143
161
  this.child = undefined;
144
162
  this.settled = false;
145
163
  this.closed = false;
@@ -266,11 +284,23 @@ class ChildExecution {
266
284
  finishClose() {
267
285
  if (this.closed) return;
268
286
  this.closed = true;
287
+ this.reportUsage();
269
288
  this.clearForceKill();
270
289
  removeTemporary(this.temporary);
271
290
  this.resolveFinished();
272
291
  }
273
292
 
293
+ reportUsage() {
294
+ if (this.usageReported) return;
295
+ this.usageReported = true;
296
+ try {
297
+ this.onUsage({
298
+ ...this.usageContext,
299
+ usage: parseAgentUsage(this.agent, childOutput(this.stdout)),
300
+ });
301
+ } catch {}
302
+ }
303
+
274
304
  resolve(value) {
275
305
  this.settle(this.resolvePromise, value);
276
306
  }
@@ -289,6 +319,7 @@ class ChildExecution {
289
319
 
290
320
  closeChild(status, signal) {
291
321
  this.clearForceKill();
322
+ this.reportUsage();
292
323
  if (!this.settled) {
293
324
  const error = closeError(this, status, signal);
294
325
  if (error) this.reject(error);
@@ -314,7 +345,7 @@ function trackExecution(executions, execution) {
314
345
  return execution;
315
346
  }
316
347
 
317
- function runRequest(options, executions, request) {
348
+ function runRequest(options, executions, request, usageContext = {}) {
318
349
  const prepared = preparedProviderRequest(options, request);
319
350
  const controller = new AbortController();
320
351
  const execution = providerChild({
@@ -323,6 +354,8 @@ function runRequest(options, executions, request) {
323
354
  agent: options.agent,
324
355
  signal: controller.signal,
325
356
  temporary: prepared.temporary,
357
+ onUsage: options.onUsage,
358
+ usageContext,
326
359
  });
327
360
  const tracked = trackExecution(executions, execution);
328
361
  return {
@@ -342,8 +375,8 @@ export function createCodingAgentChatProvider(options = {}) {
342
375
  const configured = providerOptions(options);
343
376
  const executions = new Set();
344
377
  return {
345
- run(request) {
346
- return runRequest(configured, executions, request);
378
+ run(request, context) {
379
+ return runRequest(configured, executions, request, context);
347
380
  },
348
381
  close() {
349
382
  closeExecutions(executions);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { randomBytes, timingSafeEqual } from 'node:crypto';
4
- import { watchFile, unwatchFile } from 'node:fs';
3
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
4
+ import { readFileSync, watchFile, unwatchFile } from 'node:fs';
5
5
  import { readFile, stat } from 'node:fs/promises';
6
6
  import { createServer } from 'node:http';
7
7
  import { dirname, extname, resolve, sep } from 'node:path';
@@ -11,6 +11,14 @@ import {
11
11
  createCodingAgentChatProvider,
12
12
  createReviewChat,
13
13
  } from './review-chat.mjs';
14
+ import { publicError } from './review-chat-context.mjs';
15
+ import {
16
+ emptyUsageAccumulator,
17
+ formatReviewUsage,
18
+ recordUsage,
19
+ reviewUsage,
20
+ usageSummary,
21
+ } from './agent-usage.mjs';
14
22
 
15
23
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
24
  const clientRoot = resolve(root, 'dist');
@@ -37,6 +45,7 @@ const chatAgent = option('--chat-agent');
37
45
  const chatBinary = option('--chat-binary', chatAgent);
38
46
  const chatModel = option('--chat-model');
39
47
  const chatReasoning = option('--chat-reasoning');
48
+ const chatFast = rawArgs.includes('--chat-fast');
40
49
  const chatAccessMode = option('--chat-access-mode', 'snapshot-only');
41
50
  const chatAccessRoot = option('--chat-access-root');
42
51
  if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) {
@@ -265,7 +274,7 @@ function matchingAccess(value, expectedValue) {
265
274
 
266
275
  async function fetchAsset(url) {
267
276
  if (url.pathname === '/diff-data.json') {
268
- return fileResponse(output, { live: true });
277
+ return liveSnapshotResponse();
269
278
  }
270
279
  const pathname = decodeURIComponent(url.pathname);
271
280
  const file = resolve(
@@ -278,6 +287,44 @@ async function fetchAsset(url) {
278
287
  return fileResponse(file);
279
288
  }
280
289
 
290
+ async function parsedLiveSnapshot() {
291
+ try {
292
+ return JSON.parse(await readFile(output, 'utf8'));
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
297
+
298
+ function liveSnapshotVersion(snapshot, usage) {
299
+ return createHash('sha256')
300
+ .update(JSON.stringify({ version: snapshot.version, usage }))
301
+ .digest('hex')
302
+ .slice(0, 12);
303
+ }
304
+
305
+ function snapshotNoteSummary(snapshot) {
306
+ return snapshot?.usage?.agentNotes ||
307
+ usageSummary(emptyUsageAccumulator());
308
+ }
309
+
310
+ function snapshotResponse(snapshot, usage) {
311
+ return jsonResponse({
312
+ ...snapshot,
313
+ version: liveSnapshotVersion(snapshot, usage),
314
+ usage,
315
+ });
316
+ }
317
+
318
+ async function liveSnapshotResponse() {
319
+ const snapshot = await parsedLiveSnapshot();
320
+ if (!snapshot) return fileResponse(output, { live: true });
321
+ const fingerprint = snapshot?.notes?.reviewFingerprint;
322
+ if (!fingerprint) return fileResponse(output, { live: true });
323
+ const chatSummary = currentChatUsage(fingerprint);
324
+ const usage = reviewUsage(snapshotNoteSummary(snapshot), chatSummary);
325
+ return snapshotResponse(snapshot, usage);
326
+ }
327
+
281
328
  function jsonResponse(value, status = 200) {
282
329
  return webResponse(JSON.stringify(value), {
283
330
  status,
@@ -366,6 +413,85 @@ function broadcastEvent(name, data = {}) {
366
413
  }
367
414
  }
368
415
 
416
+ function lifecycleKind(kind) {
417
+ return kind === 'compact' ? 'history compaction' : 'answer';
418
+ }
419
+
420
+ function lifecycleStatus(type) {
421
+ return {
422
+ start: 'started',
423
+ progress: 'still running',
424
+ complete: 'completed',
425
+ failure: 'failed',
426
+ timeout: 'timed out',
427
+ cancel: 'cancelled',
428
+ }[type] || type;
429
+ }
430
+
431
+ function elapsedText(milliseconds) {
432
+ if (milliseconds < 1_000) return `${milliseconds}ms`;
433
+ return `${Math.round(milliseconds / 1_000)}s`;
434
+ }
435
+
436
+ function logChatLifecycle(event) {
437
+ const elapsed = event.type === 'start' ? '' : ` (${elapsedText(event.elapsedMs)})`;
438
+ const error = event.error ? `: ${event.error}` : '';
439
+ console.log(
440
+ `Diffsplain chat #${event.runId}: ${lifecycleKind(event.kind)} ${lifecycleStatus(event.type)}${elapsed}${error}`,
441
+ );
442
+ }
443
+
444
+ function logObservedError(area, error) {
445
+ console.log(`Diffsplain ${area.replaceAll('-', ' ')} error: ${publicError(error)}`);
446
+ }
447
+
448
+ let chatUsageFingerprint;
449
+ let chatUsage = emptyUsageAccumulator();
450
+ let reviewChat;
451
+
452
+ function snapshotUsageState() {
453
+ try {
454
+ const snapshot = JSON.parse(readFileSync(output, 'utf8'));
455
+ return {
456
+ agentNotes: snapshot.usage?.agentNotes,
457
+ fingerprint: snapshot.notes?.reviewFingerprint,
458
+ };
459
+ } catch {
460
+ return {};
461
+ }
462
+ }
463
+
464
+ function currentChatUsage(fingerprint) {
465
+ return fingerprint === chatUsageFingerprint
466
+ ? usageSummary(chatUsage)
467
+ : usageSummary(emptyUsageAccumulator());
468
+ }
469
+
470
+ function resetChatUsage(fingerprint) {
471
+ if (fingerprint === chatUsageFingerprint) return;
472
+ chatUsageFingerprint = fingerprint;
473
+ chatUsage = emptyUsageAccumulator();
474
+ }
475
+
476
+ function recordChatUsage({ reviewFingerprint, usage }) {
477
+ const snapshot = snapshotUsageState();
478
+ if (
479
+ !reviewFingerprint ||
480
+ snapshot.fingerprint !== reviewFingerprint ||
481
+ reviewChat?.getState().fingerprint !== reviewFingerprint
482
+ ) {
483
+ return;
484
+ }
485
+ resetChatUsage(reviewFingerprint);
486
+ chatUsage = recordUsage(chatUsage, usage);
487
+ const totals = reviewUsage(
488
+ snapshot.agentNotes || usageSummary(emptyUsageAccumulator()),
489
+ usageSummary(chatUsage),
490
+ );
491
+ console.log(formatReviewUsage(totals));
492
+ broadcastEvent('update');
493
+ }
494
+
369
495
  const chatProviderAccess = chatAccessMode === 'checkout-read-only'
370
496
  ? { mode: chatAccessMode, root: resolve(chatAccessRoot) }
371
497
  : { mode: chatAccessMode, reason: 'target-mismatch' };
@@ -375,14 +501,17 @@ const chatProvider = chatAgent
375
501
  binary: chatBinary,
376
502
  model: chatModel,
377
503
  reasoning: chatReasoning,
504
+ fast: chatFast,
378
505
  accessMode: chatProviderAccess,
506
+ onUsage: recordChatUsage,
379
507
  })
380
508
  : undefined;
381
- const reviewChat = createReviewChat({
509
+ reviewChat = createReviewChat({
382
510
  snapshotPath: chatSnapshot,
383
511
  provider: chatProvider,
384
512
  accessMode: chatProviderAccess,
385
513
  onChange: () => broadcastEvent('chat'),
514
+ onLifecycle: logChatLifecycle,
386
515
  });
387
516
 
388
517
  function requestAuthorityError(request) {
@@ -473,6 +602,7 @@ async function sendChatCommand(request, response) {
473
602
  const status = result.accepted ? 202 : 200;
474
603
  await send(response, jsonResponse(result.state, status));
475
604
  } catch (error) {
605
+ logObservedError('chat-command', error);
476
606
  const failure = chatCommandError(error);
477
607
  await send(response, jsonResponse({ error: failure.message }, failure.status));
478
608
  }
@@ -565,9 +695,10 @@ async function containHandlerFailure(response) {
565
695
  }
566
696
 
567
697
  const server = createServer((request, response) => {
568
- void handleRequest(request, response).catch(() =>
569
- containHandlerFailure(response),
570
- );
698
+ void handleRequest(request, response).catch((error) => {
699
+ logObservedError('request-handler', error);
700
+ return containHandlerFailure(response);
701
+ });
571
702
  });
572
703
 
573
704
  watchFile(output, { interval: 100 }, (current, previous) => {
@@ -575,6 +706,7 @@ watchFile(output, { interval: 100 }, (current, previous) => {
575
706
  return;
576
707
  }
577
708
  reviewChat.refresh();
709
+ resetChatUsage(reviewChat.getState().fingerprint);
578
710
  broadcastEvent('update');
579
711
  });
580
712