diffsplain 0.9.0 → 0.10.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 (85) hide show
  1. package/README.md +20 -4
  2. package/dist/assets/{angular-html-m_m91ZYB.js → angular-html-2u-u5H-N.js} +1 -1
  3. package/dist/assets/{angular-ts-CO5Z--Rb.js → angular-ts-4_HVGlKp.js} +1 -1
  4. package/dist/assets/{apl-ogYYgLUu.js → apl-icaNWZX3.js} +1 -1
  5. package/dist/assets/{astro-DAfY3cNR.js → astro-HvIOE4vz.js} +1 -1
  6. package/dist/assets/{blade-E6RzsLUV.js → blade-BpKJV8ev.js} +1 -1
  7. package/dist/assets/{c-kKdxhtHF.js → c-BNvtxyRQ.js} +1 -1
  8. package/dist/assets/{cobol-3oDDyFNf.js → cobol-8js4hfxa.js} +1 -1
  9. package/dist/assets/{coffee-dsiILuWe.js → coffee-DXg_piQ9.js} +1 -1
  10. package/dist/assets/{cpp-DYyNFq__.js → cpp-BZDFAjyh.js} +1 -1
  11. package/dist/assets/{crystal-B7dVp9TF.js → crystal-aCF3ira4.js} +1 -1
  12. package/dist/assets/{css-DlxJ0aex.js → css-DfRtUkhE.js} +1 -1
  13. package/dist/assets/{edge-BjEVphkC.js → edge-vS6_AmQv.js} +1 -1
  14. package/dist/assets/{elixir-0xTOtMcM.js → elixir-Ce0nUHSq.js} +1 -1
  15. package/dist/assets/{elm-iQk_Wrxq.js → elm-DmaeyizZ.js} +1 -1
  16. package/dist/assets/{erb-BO74ztiE.js → erb-DD6OUyvt.js} +1 -1
  17. package/dist/assets/{git-rebase-BN667QRY.js → git-rebase-HbJMn7iS.js} +1 -1
  18. package/dist/assets/{glimmer-js-DTGUIHeS.js → glimmer-js-BRuqbNaV.js} +1 -1
  19. package/dist/assets/{glimmer-ts-BU3oQn2d.js → glimmer-ts-ZBpkdEEA.js} +1 -1
  20. package/dist/assets/{glsl-KLiqLJi2.js → glsl-CrQ4GScf.js} +1 -1
  21. package/dist/assets/{graphql-D_idcsoJ.js → graphql-Tn6PBdQY.js} +1 -1
  22. package/dist/assets/{hack-BXlsgPIr.js → hack-CoApvrbo.js} +1 -1
  23. package/dist/assets/{haml-fP0aUg4-.js → haml-COKq8Xjp.js} +1 -1
  24. package/dist/assets/{handlebars-C1ZEaot-.js → handlebars-CAna2DTA.js} +1 -1
  25. package/dist/assets/{html-D04qIuup.js → html-Ch4xJ7YF.js} +1 -1
  26. package/dist/assets/{html-derivative-C6ai-9QL.js → html-derivative-0ITK-MAI.js} +1 -1
  27. package/dist/assets/{http-C7bz3ZvQ.js → http-DiMWQ8Qr.js} +1 -1
  28. package/dist/assets/{hurl-jL6B6Ahr.js → hurl-OcXymC0J.js} +1 -1
  29. package/dist/assets/{index-BPaJGi-m.js → index-BmcCug2M.js} +4 -4
  30. package/dist/assets/{index-VAyBpb3u.css → index-CADUNiTy.css} +1 -1
  31. package/dist/assets/{java-BVzvLCON.js → java-CFb3qN4r.js} +1 -1
  32. package/dist/assets/{javascript-UDbZHxCI.js → javascript-CRmk2JrF.js} +1 -1
  33. package/dist/assets/{jinja-DB-QKo2A.js → jinja-Da6lDTEy.js} +1 -1
  34. package/dist/assets/{jison-B1ZbR2FF.js → jison-Bnivzmtu.js} +1 -1
  35. package/dist/assets/{json-DV4WwJzT.js → json-m8vJP_KK.js} +1 -1
  36. package/dist/assets/{jsx-DS6_VTvW.js → jsx-C3HX_Sbu.js} +1 -1
  37. package/dist/assets/{julia-C4tevET0.js → julia-Ckg-wSHA.js} +1 -1
  38. package/dist/assets/{just-CWMjefzB.js → just-DCjIAyvO.js} +1 -1
  39. package/dist/assets/{latex-tyEFf_Af.js → latex-CrYSm4pl.js} +1 -1
  40. package/dist/assets/{liquid-C3d1X1rL.js → liquid-MQUFSPYU.js} +1 -1
  41. package/dist/assets/{lua-sXvrzLQY.js → lua-DcuzJJ77.js} +1 -1
  42. package/dist/assets/{marko-CQweucif.js → marko-4JNmQaB4.js} +1 -1
  43. package/dist/assets/{mdc-TrxLWF7s.js → mdc-C_j8HvRL.js} +1 -1
  44. package/dist/assets/{nginx-ChE2nSNJ.js → nginx-ta3Gn1HE.js} +1 -1
  45. package/dist/assets/{nim-BY1CTRfx.js → nim-BwWLst9M.js} +1 -1
  46. package/dist/assets/{perl-Cvj2PQQH.js → perl-DfW7Uq5O.js} +1 -1
  47. package/dist/assets/{php-CGmHp8HW.js → php-DGf5G3Cf.js} +1 -1
  48. package/dist/assets/{pug-DM4S0bRr.js → pug-CWI-NaYz.js} +1 -1
  49. package/dist/assets/{qml-DI89Al9K.js → qml-Csblgh7U.js} +1 -1
  50. package/dist/assets/{r-Bs5_4yYh.js → r-QGooaGDI.js} +1 -1
  51. package/dist/assets/{razor-DimzayhZ.js → razor-Dp9ihLYf.js} +1 -1
  52. package/dist/assets/{regexp-BLACz0cx.js → regexp-DwUnLcVr.js} +1 -1
  53. package/dist/assets/{rst-DcWzxQh2.js → rst-BY8GIwDJ.js} +1 -1
  54. package/dist/assets/{ruby-Dlat5E8d.js → ruby-VgzI9htT.js} +1 -1
  55. package/dist/assets/{sas-CCDmMaBY.js → sas-DYLsm-0j.js} +1 -1
  56. package/dist/assets/{scss-BPiU2nu6.js → scss-tIFkeUQX.js} +1 -1
  57. package/dist/assets/{shellscript-CKinVNrN.js → shellscript-CHjuQ4Pl.js} +1 -1
  58. package/dist/assets/{shellsession-Cea8Pu5d.js → shellsession-Bv0S0hj9.js} +1 -1
  59. package/dist/assets/{soy--BRAPY4-.js → soy-D6U1tZod.js} +1 -1
  60. package/dist/assets/{sql-DjKiCyWK.js → sql-CGz8kjKv.js} +1 -1
  61. package/dist/assets/{stata-CDvo3J6y.js → stata-C9ytERUq.js} +1 -1
  62. package/dist/assets/{surrealql-C7b7E5sh.js → surrealql-DRfRnaYu.js} +1 -1
  63. package/dist/assets/{svelte-DO0tKtiK.js → svelte-wYnk_1sI.js} +1 -1
  64. package/dist/assets/{templ-DhhmR_rv.js → templ-DWrGVE0u.js} +1 -1
  65. package/dist/assets/{tex-Cn_V-Gg4.js → tex-CYTzoUJh.js} +1 -1
  66. package/dist/assets/{ts-tags-B6c-FTmA.js → ts-tags-1TIXEGea.js} +1 -1
  67. package/dist/assets/{tsx-Cvtt2sJ-.js → tsx-a3BoAoOh.js} +1 -1
  68. package/dist/assets/{twig-B7ZbCUmB.js → twig-e3dvNv6o.js} +1 -1
  69. package/dist/assets/{typescript-i3C6um2B.js → typescript-BBhnpnTf.js} +1 -1
  70. package/dist/assets/{vue-DyPX0Vum.js → vue-DzzVbu9q.js} +1 -1
  71. package/dist/assets/{vue-html-D362KpK3.js → vue-html-quBNv38K.js} +1 -1
  72. package/dist/assets/{vue-vine-D34cBbwO.js → vue-vine-CDuR69FE.js} +1 -1
  73. package/dist/assets/{xml-DJEk7u4n.js → xml-DMifOLSe.js} +1 -1
  74. package/dist/assets/{xsl-CoTtf90f.js → xsl-D8Ef-5HK.js} +1 -1
  75. package/dist/assets/{yaml-Cv5epQ4-.js → yaml-5c38gpmA.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +3 -2
  78. package/scripts/agent-config.mjs +184 -0
  79. package/scripts/cli-args.mjs +29 -2
  80. package/scripts/coding-agents.mjs +57 -6
  81. package/scripts/generate-summaries.mjs +11 -4
  82. package/scripts/present.mjs +140 -57
  83. package/scripts/review-chat-controller.mjs +137 -14
  84. package/scripts/review-chat-provider.mjs +14 -3
  85. package/scripts/serve-built.mjs +39 -3
@@ -17,9 +17,11 @@ import { dirname, join, resolve } from 'node:path';
17
17
  import { createInterface } from 'node:readline';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { helpText, parseCliArgs } from './cli-args.mjs';
20
+ import { applyAgentConfigOperation } from './agent-config.mjs';
20
21
  import {
21
22
  agentReadOnlyWarning,
22
23
  assertReasoningSupported,
24
+ codingAgentFromSelectionError,
23
25
  codingAgentAvailability,
24
26
  codingAgentBinary,
25
27
  selectCodingAgent,
@@ -126,6 +128,22 @@ if (cli.version) {
126
128
  console.log(`diffsplain ${packageJson.version}`);
127
129
  process.exit(0);
128
130
  }
131
+ if (cli.config) {
132
+ try {
133
+ const result = applyAgentConfigOperation(cli.config);
134
+ if (result.kind === 'show') {
135
+ console.log(result.agent ?? 'No default coding agent is configured.');
136
+ } else if (result.kind === 'set') {
137
+ console.log(`Default coding agent set to "${result.agent}".`);
138
+ } else {
139
+ console.log('Default coding agent unset.');
140
+ }
141
+ process.exit(0);
142
+ } catch (error) {
143
+ console.error(error instanceof Error ? error.message : String(error));
144
+ process.exit(1);
145
+ }
146
+ }
129
147
  if (cli.doctor) {
130
148
  if (cli.doctor.deep) {
131
149
  console.error(
@@ -263,7 +281,12 @@ if (agentEnabled) {
263
281
  agentArgs.push('--provider-read-only-warning-emitted');
264
282
  }
265
283
  } catch (error) {
266
- recordSelectedProvider(cli.agent || 'unknown');
284
+ recordSelectedProvider(
285
+ codingAgentFromSelectionError(error) ||
286
+ selectedAgent ||
287
+ cli.agent ||
288
+ 'unknown',
289
+ );
267
290
  supportRecorder?.addStage(
268
291
  'agent',
269
292
  performance.now() - selectionStarted,
@@ -338,9 +361,7 @@ writeFileSync(accessPath, access, { mode: 0o600 });
338
361
  chmodSync(accessPath, 0o600);
339
362
  if (agentEnabled) {
340
363
  feedArgs.push('--ignore-summary-watch');
341
- if (accessMode.mode === 'snapshot-only') {
342
- agentArgs.push('--snapshot', rawSnapshotPath);
343
- }
364
+ agentArgs.push('--snapshot', rawSnapshotPath);
344
365
  }
345
366
 
346
367
  const snapshotStarted = performance.now();
@@ -506,59 +527,91 @@ function startSite() {
506
527
  child.on('error', handleSiteError);
507
528
  }
508
529
 
530
+ function savedReviewFingerprint(notes) {
531
+ if (!notes) return undefined;
532
+ if (notes.agentReviewFingerprint) return notes.agentReviewFingerprint;
533
+ return notes.reviewFingerprint;
534
+ }
535
+
536
+ function snapshotReviewFingerprint(snapshot) {
537
+ const savedFingerprint = savedReviewFingerprint(snapshot.notes);
538
+ if (savedFingerprint) return savedFingerprint;
539
+ const reviewData = {
540
+ repo: {
541
+ name: snapshot.repo.name,
542
+ base: snapshot.repo.base,
543
+ head: snapshot.repo.head,
544
+ branch: snapshot.repo.branch,
545
+ baseBranch: snapshot.repo.baseBranch,
546
+ remote: snapshot.repo.remote,
547
+ targetKind: snapshot.repo.target?.kind,
548
+ },
549
+ change: {
550
+ title: snapshot.change.title,
551
+ number: snapshot.change.number,
552
+ },
553
+ files: snapshot.files.map((file) => ({
554
+ path: file.path,
555
+ oldPath: file.oldPath,
556
+ status: file.status,
557
+ additions: file.additions,
558
+ deletions: file.deletions,
559
+ isBinary: file.isBinary,
560
+ patch: file.patch,
561
+ })),
562
+ };
563
+ return createHash('sha256')
564
+ .update(JSON.stringify(reviewData))
565
+ .digest('hex');
566
+ }
567
+
568
+ function noteMetadataMatches(notes, fingerprint) {
569
+ return [
570
+ notes.complete,
571
+ notes.fresh,
572
+ notes.generatedFor === fingerprint,
573
+ notes.accessMode === accessMode.mode,
574
+ ].every(Boolean);
575
+ }
576
+
577
+ function normalizedAgentSetting(value) {
578
+ return value ?? null;
579
+ }
580
+
581
+ function agentSettingsMatch(notes) {
582
+ return [
583
+ notes.agent === selectedAgent,
584
+ normalizedAgentSetting(notes.model) === normalizedAgentSetting(cli.model),
585
+ normalizedAgentSetting(notes.reasoning) ===
586
+ normalizedAgentSetting(cli.reasoning),
587
+ ].every(Boolean);
588
+ }
589
+
590
+ function emptyReview(files) {
591
+ return Array.isArray(files) && files.length === 0;
592
+ }
593
+
594
+ function hasCurrentAgentNotes(snapshot, fingerprint) {
595
+ const notes = snapshot.notes;
596
+ if (!savedReviewFingerprint(notes)) return false;
597
+ if (!noteMetadataMatches(notes, fingerprint)) return false;
598
+ if (emptyReview(snapshot.files)) return true;
599
+ return agentSettingsMatch(notes);
600
+ }
601
+
602
+ function snapshotStateFromSnapshot(snapshot) {
603
+ const fingerprint = snapshotReviewFingerprint(snapshot);
604
+ return {
605
+ fingerprint,
606
+ hasCurrentAgentNotes: hasCurrentAgentNotes(snapshot, fingerprint),
607
+ };
608
+ }
609
+
509
610
  function snapshotState() {
510
611
  try {
511
- const snapshot = JSON.parse(readFileSync(reviewSnapshotPath, 'utf8'));
512
- if (snapshot.notes?.reviewFingerprint) {
513
- const fingerprint =
514
- snapshot.notes.agentReviewFingerprint ||
515
- snapshot.notes.reviewFingerprint;
516
- const emptyReview =
517
- Array.isArray(snapshot.files) && snapshot.files.length === 0;
518
- return {
519
- fingerprint,
520
- hasCurrentAgentNotes:
521
- snapshot.notes.complete &&
522
- snapshot.notes.fresh &&
523
- snapshot.notes.generatedFor === fingerprint &&
524
- snapshot.notes.accessMode === accessMode.mode &&
525
- (emptyReview ||
526
- (snapshot.notes.agent === selectedAgent &&
527
- (snapshot.notes.model || null) === (cli.model || null) &&
528
- (snapshot.notes.reasoning || null) ===
529
- (cli.reasoning || null))),
530
- };
531
- }
532
- const reviewData = {
533
- repo: {
534
- name: snapshot.repo.name,
535
- base: snapshot.repo.base,
536
- head: snapshot.repo.head,
537
- branch: snapshot.repo.branch,
538
- baseBranch: snapshot.repo.baseBranch,
539
- remote: snapshot.repo.remote,
540
- targetKind: snapshot.repo.target?.kind,
541
- },
542
- change: {
543
- title: snapshot.change.title,
544
- number: snapshot.change.number,
545
- },
546
- files: snapshot.files.map((file) => ({
547
- path: file.path,
548
- oldPath: file.oldPath,
549
- status: file.status,
550
- additions: file.additions,
551
- deletions: file.deletions,
552
- isBinary: file.isBinary,
553
- patch: file.patch,
554
- })),
555
- };
556
- return {
557
- fingerprint: createHash('sha256')
558
- .update(JSON.stringify(reviewData))
559
- .digest('hex'),
560
- hasCurrentAgentNotes: false,
561
- };
612
+ return snapshotStateFromSnapshot(
613
+ JSON.parse(readFileSync(reviewSnapshotPath, 'utf8')),
614
+ );
562
615
  } catch {
563
616
  return undefined;
564
617
  }
@@ -699,12 +752,16 @@ function scheduleAgent(fingerprint) {
699
752
  if (agent) {
700
753
  if (selectedFingerprint !== agentFingerprint) {
701
754
  queuedFingerprint = selectedFingerprint;
755
+ console.log(
756
+ 'Review changed while notes were generated. Restarting agent notes...',
757
+ );
702
758
  agent.kill('SIGTERM');
703
759
  }
704
760
  return;
705
761
  }
706
762
  clearTimeout(agentTimer);
707
763
  const delay = agentFingerprint ? 300 : 0;
764
+ console.log('Preparing agent notes...');
708
765
  agentTimer = setTimeout(() => runAgent(selectedFingerprint), delay);
709
766
  }
710
767
 
@@ -720,11 +777,37 @@ function markSnapshotReady() {
720
777
  );
721
778
  }
722
779
 
780
+ function snapshotForPresentation(snapshot) {
781
+ if (snapshotStateFromSnapshot(snapshot).hasCurrentAgentNotes) {
782
+ return snapshot;
783
+ }
784
+ const content = {
785
+ ...snapshot,
786
+ notes: {
787
+ ...snapshot.notes,
788
+ complete: false,
789
+ status: 'generating',
790
+ },
791
+ };
792
+ delete content.version;
793
+ delete content.generatedAt;
794
+ return {
795
+ version: createHash('sha256')
796
+ .update(JSON.stringify(content))
797
+ .digest('hex')
798
+ .slice(0, 12),
799
+ generatedAt: new Date().toISOString(),
800
+ ...content,
801
+ };
802
+ }
803
+
723
804
  function seedPresentationSnapshot() {
724
- const raw = readFileSync(rawSnapshotPath);
805
+ const snapshot = snapshotForPresentation(
806
+ JSON.parse(readFileSync(rawSnapshotPath, 'utf8')),
807
+ );
725
808
  mkdirSync(dirname(outputPath), { recursive: true });
726
809
  const pendingOutput = `${outputPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
727
- writeFileSync(pendingOutput, raw);
810
+ writeFileSync(pendingOutput, `${JSON.stringify(snapshot, null, 2)}\n`);
728
811
  renameSync(pendingOutput, outputPath);
729
812
  }
730
813
 
@@ -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')) };
@@ -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,6 +423,7 @@ export class ReviewChatController {
323
423
  thread.status = run.kind === 'compact' ? 'compacting' : 'running';
324
424
  thread.error = undefined;
325
425
  this.notify();
426
+ this.supervise(thread, run);
326
427
  const result = executionResult(this.provider, input);
327
428
  if (result.error) {
328
429
  this.finishFailure(thread, run, result.error);
@@ -401,13 +502,35 @@ export class ReviewChatController {
401
502
  }
402
503
  }
403
504
 
505
+ timeoutRun(thread, run) {
506
+ if (!this.currentRun(thread, run)) return;
507
+ const error = timeoutError(this.runTimeoutMs);
508
+ if (!this.finishLifecycle(run, 'timeout', error)) return;
509
+ this.clearRun(thread);
510
+ this.cancelProvider(run);
511
+ thread.error = publicError(error);
512
+ Object.assign(thread, runState(run));
513
+ this.notify();
514
+ }
515
+
516
+ prepareCancellation(thread, run) {
517
+ if (!this.finishLifecycle(run, 'cancel')) return false;
518
+ this.clearRun(thread);
519
+ this.cancelProvider(run);
520
+ return true;
521
+ }
522
+
523
+ restoreCancelledState(thread, run, stale) {
524
+ if (stale) return;
525
+ Object.assign(thread, cancelledState(run));
526
+ thread.error = undefined;
527
+ }
528
+
404
529
  cancelRun(thread, { stale = false } = {}) {
405
530
  const run = thread.run;
406
531
  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;
532
+ if (!this.prepareCancellation(thread, run)) return false;
533
+ this.restoreCancelledState(thread, run, stale);
411
534
  return true;
412
535
  }
413
536
 
@@ -41,10 +41,21 @@ function childOutput(chunks) {
41
41
  return Buffer.concat(chunks).toString('utf8');
42
42
  }
43
43
 
44
- function childFailure(agent, status, signal, stdout, stderr) {
44
+ function childStatus(status, signal) {
45
+ return status ?? signal;
46
+ }
47
+
48
+ function childDiagnosticSuffix(stdout, stderr) {
45
49
  const detail = stderr.trim() || stdout.trim();
46
- const suffix = detail ? `: ${detail.slice(-600)}` : '';
47
- return new Error(`${agent} exited with status ${status ?? signal}${suffix}`);
50
+ return detail ? `: ${detail.slice(-600)}` : '';
51
+ }
52
+
53
+ function childFailure(agent, status, signal, stdout, stderr) {
54
+ const observedStatus = childStatus(status, signal);
55
+ const suffix = childDiagnosticSuffix(stdout, stderr);
56
+ const error = new Error(`${agent} exited with status ${observedStatus}${suffix}`);
57
+ error.lifecycleMessage = `${agent} exited with status ${observedStatus}`;
58
+ return error;
48
59
  }
49
60
 
50
61
  function aborted(signal) {
@@ -11,6 +11,7 @@ import {
11
11
  createCodingAgentChatProvider,
12
12
  createReviewChat,
13
13
  } from './review-chat.mjs';
14
+ import { publicError } from './review-chat-context.mjs';
14
15
 
15
16
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const clientRoot = resolve(root, 'dist');
@@ -366,6 +367,38 @@ function broadcastEvent(name, data = {}) {
366
367
  }
367
368
  }
368
369
 
370
+ function lifecycleKind(kind) {
371
+ return kind === 'compact' ? 'history compaction' : 'answer';
372
+ }
373
+
374
+ function lifecycleStatus(type) {
375
+ return {
376
+ start: 'started',
377
+ progress: 'still running',
378
+ complete: 'completed',
379
+ failure: 'failed',
380
+ timeout: 'timed out',
381
+ cancel: 'cancelled',
382
+ }[type] || type;
383
+ }
384
+
385
+ function elapsedText(milliseconds) {
386
+ if (milliseconds < 1_000) return `${milliseconds}ms`;
387
+ return `${Math.round(milliseconds / 1_000)}s`;
388
+ }
389
+
390
+ function logChatLifecycle(event) {
391
+ const elapsed = event.type === 'start' ? '' : ` (${elapsedText(event.elapsedMs)})`;
392
+ const error = event.error ? `: ${event.error}` : '';
393
+ console.log(
394
+ `Diffsplain chat #${event.runId}: ${lifecycleKind(event.kind)} ${lifecycleStatus(event.type)}${elapsed}${error}`,
395
+ );
396
+ }
397
+
398
+ function logObservedError(area, error) {
399
+ console.log(`Diffsplain ${area.replaceAll('-', ' ')} error: ${publicError(error)}`);
400
+ }
401
+
369
402
  const chatProviderAccess = chatAccessMode === 'checkout-read-only'
370
403
  ? { mode: chatAccessMode, root: resolve(chatAccessRoot) }
371
404
  : { mode: chatAccessMode, reason: 'target-mismatch' };
@@ -383,6 +416,7 @@ const reviewChat = createReviewChat({
383
416
  provider: chatProvider,
384
417
  accessMode: chatProviderAccess,
385
418
  onChange: () => broadcastEvent('chat'),
419
+ onLifecycle: logChatLifecycle,
386
420
  });
387
421
 
388
422
  function requestAuthorityError(request) {
@@ -473,6 +507,7 @@ async function sendChatCommand(request, response) {
473
507
  const status = result.accepted ? 202 : 200;
474
508
  await send(response, jsonResponse(result.state, status));
475
509
  } catch (error) {
510
+ logObservedError('chat-command', error);
476
511
  const failure = chatCommandError(error);
477
512
  await send(response, jsonResponse({ error: failure.message }, failure.status));
478
513
  }
@@ -565,9 +600,10 @@ async function containHandlerFailure(response) {
565
600
  }
566
601
 
567
602
  const server = createServer((request, response) => {
568
- void handleRequest(request, response).catch(() =>
569
- containHandlerFailure(response),
570
- );
603
+ void handleRequest(request, response).catch((error) => {
604
+ logObservedError('request-handler', error);
605
+ return containHandlerFailure(response);
606
+ });
571
607
  });
572
608
 
573
609
  watchFile(output, { interval: 100 }, (current, previous) => {