@in-the-loop-labs/pair-review 3.5.2 → 3.7.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 +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -0,0 +1,290 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const { broadcastReviewEvent } = require('../events/review-events');
4
+ const logger = require('../utils/logger');
5
+ const { makeAbortError } = require('./abort-signal-wiring');
6
+
7
+ const BACKGROUND_QUEUE_CONCURRENCY = 2;
8
+
9
+ const defaults = {
10
+ broadcast: broadcastReviewEvent,
11
+ };
12
+
13
+ /**
14
+ * Bounded-concurrency in-process queue with per-key dedup.
15
+ *
16
+ * Jobs are keyed by `${reviewId}:${jobType}`; concurrent enqueues
17
+ * for the same key share a single execution and a single promise.
18
+ *
19
+ * Cancellation: each enqueued job is associated with an `AbortController`.
20
+ * The worker thunk is invoked as `fn(signal)` so it can plumb the signal
21
+ * into downstream provider calls / `fetch` / `child_process.spawn`.
22
+ * Callers cancel via `cancel(reviewId, jobKey)`, which aborts the
23
+ * signal and removes the controller; the worker is expected to react
24
+ * to the abort and settle (typically by rejecting with an AbortError).
25
+ */
26
+ class BackgroundQueue {
27
+ /**
28
+ * @param {Object} [options]
29
+ * @param {number} [options.concurrency] - Max concurrent jobs.
30
+ * @param {Object} [options._deps] - Override dependencies (testing).
31
+ * @param {Function} [options._deps.broadcast] - Broadcast hook.
32
+ */
33
+ constructor(options = {}) {
34
+ const { concurrency = BACKGROUND_QUEUE_CONCURRENCY, _deps = {} } = options;
35
+ this.concurrency = concurrency;
36
+ this.active = 0;
37
+ this.queue = [];
38
+ this.inFlight = new Map();
39
+ // Per-key AbortController covers both queued and running jobs so a
40
+ // single lookup can resolve cancellation against either state.
41
+ this.controllers = new Map();
42
+ this._deps = { ...defaults, ..._deps };
43
+ }
44
+
45
+ /**
46
+ * Enqueue a job for execution.
47
+ *
48
+ * Dedup contract: if a job for the same `(reviewId, jobType)` key is
49
+ * already queued or running, this returns the existing promise without
50
+ * invoking `fn`. The duplicate `fn` is silently dropped.
51
+ *
52
+ * The thunk is called as `fn(signal)` where `signal` is the `AbortSignal`
53
+ * for this job. Workers that touch the network, spawn processes, or
54
+ * otherwise burn upstream resources should thread the signal through so
55
+ * cancellation actually frees those resources.
56
+ *
57
+ * @param {string|number} reviewId - Review identifier.
58
+ * @param {string} jobType - Job category (e.g. 'summaries', 'tour').
59
+ * @param {Function} fn - Thunk `(signal) => value|Promise<value>`.
60
+ * @returns {Promise} Resolves/rejects with the job result.
61
+ */
62
+ enqueue(reviewId, jobType, fn) {
63
+ const key = `${reviewId}:${jobType}`;
64
+ if (this.inFlight.has(key)) {
65
+ return this.inFlight.get(key);
66
+ }
67
+ let resolve;
68
+ let reject;
69
+ const p = new Promise((res, rej) => {
70
+ resolve = res;
71
+ reject = rej;
72
+ });
73
+ this.inFlight.set(key, p);
74
+ const controller = new AbortController();
75
+ this.controllers.set(key, controller);
76
+ this.queue.push({
77
+ key,
78
+ run: fn,
79
+ resolve,
80
+ reject,
81
+ reviewId,
82
+ jobType,
83
+ controller,
84
+ promise: p,
85
+ });
86
+ this._drain();
87
+ return p;
88
+ }
89
+
90
+ /**
91
+ * Cancel an in-flight or queued job. Aborts its `AbortSignal` so the
92
+ * worker can tear down upstream resources, then drops the controller.
93
+ *
94
+ * Matching is exact on `(reviewId, jobKey)`. For composite jobTypes
95
+ * like `summaries:${digest}`, callers may also pass a bare prefix
96
+ * (`summaries`) — this cancels ALL matching `summaries:*` jobs for the
97
+ * review. This is what the toolbar "Cancel Summaries" button needs:
98
+ * users don't know about digests, they just want the pulse to stop.
99
+ *
100
+ * @param {string|number} reviewId
101
+ * @param {string} jobKey - bare `jobType` (e.g. `tour`) or full key
102
+ * suffix (e.g. `summaries:abc123`).
103
+ * @returns {{cancelled: number}} number of jobs aborted.
104
+ */
105
+ cancel(reviewId, jobKey) {
106
+ if (jobKey === undefined || jobKey === null || jobKey === '') {
107
+ return { cancelled: 0 };
108
+ }
109
+ const exact = `${reviewId}:${jobKey}`;
110
+ const prefix = `${exact}:`;
111
+ let cancelled = 0;
112
+ // Snapshot keys before aborting — settling a worker mid-iteration would
113
+ // mutate this.controllers (via _settle).
114
+ const keys = Array.from(this.controllers.keys());
115
+ for (const key of keys) {
116
+ if (key !== exact && !key.startsWith(prefix)) continue;
117
+ const controller = this.controllers.get(key);
118
+ if (!controller) continue;
119
+ try {
120
+ controller.abort();
121
+ } catch (err) {
122
+ logger.warn(`BackgroundQueue controller.abort() failed for ${key}: ${err.message}`);
123
+ }
124
+ // Eagerly evict the cancelled key from the dedup/controller maps and
125
+ // splice any not-yet-started descriptors out of the queue. Without
126
+ // this, a follow-up enqueue() for the same key would hit the dedup
127
+ // guard and inherit the about-to-reject promise, and _drain() could
128
+ // hand the worker an already-aborted signal. _settle()'s deletes are
129
+ // identity-guarded, so when the cancelled worker eventually rejects
130
+ // it won't clobber a replacement job installed under the same key.
131
+ this._evictKey(key);
132
+ cancelled++;
133
+ }
134
+ return { cancelled };
135
+ }
136
+
137
+ /**
138
+ * Remove a key from the dedup/controller maps and reject any queued (not
139
+ * yet started) descriptor with an AbortError. Safe to call when the key
140
+ * has already been cleaned up — Map.delete and Array.splice both no-op.
141
+ *
142
+ * @param {string} key - Composite `${reviewId}:${jobType}` key.
143
+ * @private
144
+ */
145
+ _evictKey(key) {
146
+ // Splice queued descriptors and reject their promises so the dedup'd
147
+ // caller (if any) sees a clean cancellation rather than a hung promise.
148
+ for (let i = this.queue.length - 1; i >= 0; i--) {
149
+ if (this.queue[i].key !== key) continue;
150
+ const [descriptor] = this.queue.splice(i, 1);
151
+ try {
152
+ descriptor.reject(makeAbortError('Job cancelled before start'));
153
+ } catch (rejectErr) {
154
+ logger.warn(
155
+ `BackgroundQueue descriptor.reject failed for ${key}: ${rejectErr.message}`
156
+ );
157
+ }
158
+ }
159
+ this.inFlight.delete(key);
160
+ this.controllers.delete(key);
161
+ }
162
+
163
+ /** Start as many queued jobs as concurrency allows. */
164
+ _drain() {
165
+ while (this.active < this.concurrency && this.queue.length > 0) {
166
+ const descriptor = this.queue.shift();
167
+ this.active++;
168
+ Promise.resolve()
169
+ .then(() => descriptor.run(descriptor.controller.signal))
170
+ .then(
171
+ (result) => this._settle(descriptor, null, result),
172
+ (error) => this._settle(descriptor, error, undefined)
173
+ );
174
+ }
175
+ }
176
+
177
+ /** Finalize a job: free its key, broadcast, settle, and drain. */
178
+ _settle(descriptor, error, result) {
179
+ // Identity-guarded cleanup: if cancel() evicted this descriptor and a
180
+ // replacement was enqueued under the same key, the maps now point at
181
+ // the new descriptor's controller/promise — unconditional deletes would
182
+ // wipe the replacement's bookkeeping (invisible to hasActiveForReview,
183
+ // immune to cancel, vulnerable to duplicate enqueue).
184
+ if (this.controllers.get(descriptor.key) === descriptor.controller) {
185
+ this.controllers.delete(descriptor.key);
186
+ }
187
+ if (this.inFlight.get(descriptor.key) === descriptor.promise) {
188
+ this.inFlight.delete(descriptor.key);
189
+ }
190
+ this.active--;
191
+ this._onComplete(descriptor.reviewId, descriptor.jobType, error);
192
+ if (error === null) {
193
+ descriptor.resolve(result);
194
+ } else {
195
+ descriptor.reject(error);
196
+ }
197
+ this._drain();
198
+ }
199
+
200
+ /**
201
+ * Is there an in-flight or queued job for this review whose jobType
202
+ * starts with the given prefix? Useful for surfacing a "generating"
203
+ * indicator on the frontend (`hasActiveForReview(id, 'summaries')`).
204
+ *
205
+ * Job keys are stored as `${reviewId}:${jobType}`; `summaries` jobs use
206
+ * the form `summaries:${digest}`, so a prefix match on
207
+ * `${reviewId}:summaries` catches every digest variant.
208
+ *
209
+ * @param {string|number} reviewId
210
+ * @param {string} jobTypePrefix
211
+ * @returns {boolean}
212
+ */
213
+ hasActiveForReview(reviewId, jobTypePrefix) {
214
+ if (!jobTypePrefix) return false;
215
+ const prefix = `${reviewId}:${jobTypePrefix}`;
216
+ for (const key of this.inFlight.keys()) {
217
+ if (key === prefix || key.startsWith(prefix + ':')) return true;
218
+ }
219
+ return false;
220
+ }
221
+
222
+ /**
223
+ * Like `hasActiveForReview` but returns the in-flight/queued jobType string
224
+ * (without the `${reviewId}:` prefix), or null. Useful when callers track a
225
+ * versioned key like `summaries:${digest}` and need to know the *exact* key
226
+ * to cancel before re-enqueueing under a new digest.
227
+ *
228
+ * @param {string|number} reviewId
229
+ * @param {string} jobTypePrefix
230
+ * @returns {string|null}
231
+ */
232
+ findActiveJobType(reviewId, jobTypePrefix) {
233
+ if (!jobTypePrefix) return null;
234
+ const prefix = `${reviewId}:${jobTypePrefix}`;
235
+ for (const key of this.inFlight.keys()) {
236
+ if (key === prefix || key.startsWith(prefix + ':')) {
237
+ return key.slice(String(reviewId).length + 1);
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** Broadcast job completion; broadcast failures are logged, not thrown. */
244
+ _onComplete(reviewId, jobType, error) {
245
+ try {
246
+ // Include whether more jobs of the same type-prefix remain queued or
247
+ // in-flight so listeners (e.g. the summaries toolbar pulse) don't
248
+ // clear their "generating" state when a sibling job is still running.
249
+ // For composite types like `summaries:${digest}`, we strip the suffix
250
+ // so the prefix match catches every digest variant.
251
+ const colonIdx = jobType.indexOf(':');
252
+ const prefix = colonIdx >= 0 ? jobType.slice(0, colonIdx) : jobType;
253
+ const hasActiveForType = this.hasActiveForReview(reviewId, prefix);
254
+ this._deps.broadcast(reviewId, {
255
+ type: 'review:background_job_finished',
256
+ jobType,
257
+ ok: error === null,
258
+ hasActiveForType,
259
+ cancelled: isAbortError(error),
260
+ });
261
+ } catch (broadcastError) {
262
+ logger.warn(
263
+ `BackgroundQueue broadcast failed for ${reviewId}:${jobType}: ${broadcastError.message}`
264
+ );
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Recognize errors that originated from `AbortController.abort()`.
271
+ * Node sets `name === 'AbortError'` on the DOMException for AbortSignal,
272
+ * but providers that wrap the abort in a custom Error may instead set
273
+ * `code === 'ABORT_ERR'` or surface `signal.aborted` themselves. Check
274
+ * the common shapes so the broadcast payload is honest about cancels.
275
+ *
276
+ * @param {unknown} err
277
+ * @returns {boolean}
278
+ */
279
+ function isAbortError(err) {
280
+ if (!err) return false;
281
+ if (typeof err !== 'object') return false;
282
+ if (err.name === 'AbortError') return true;
283
+ if (err.code === 'ABORT_ERR') return true;
284
+ if (err.isCancellation === true) return true;
285
+ return false;
286
+ }
287
+
288
+ const backgroundQueue = new BackgroundQueue();
289
+
290
+ module.exports = { BackgroundQueue, backgroundQueue, BACKGROUND_QUEUE_CONCURRENCY, isAbortError };
@@ -100,7 +100,7 @@ class ClaudeCLI {
100
100
  }
101
101
 
102
102
  // Extract JSON from the text response using robust extraction strategies
103
- const extracted = extractJSON(stdout, level);
103
+ const extracted = extractJSON(stdout, level, levelPrefix);
104
104
  if (extracted.success) {
105
105
  logger.success(`${levelPrefix} Successfully parsed JSON response`);
106
106
  resolve(extracted.data);
@@ -12,6 +12,7 @@ const logger = require('../utils/logger');
12
12
  const { extractJSON } = require('../utils/json-extractor');
13
13
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
14
14
  const { StreamParser, parseClaudeLine } = require('./stream-parser');
15
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
15
16
 
16
17
  // Directory containing bin scripts (git-diff-lines, etc.)
17
18
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -286,7 +287,7 @@ class ClaudeProvider extends AIProvider {
286
287
  */
287
288
  async execute(prompt, options = {}) {
288
289
  return new Promise((resolve, reject) => {
289
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
290
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
290
291
 
291
292
  const levelPrefix = logPrefix || `[Level ${level}]`;
292
293
  logger.info(`${levelPrefix} Executing Claude CLI...`);
@@ -300,7 +301,12 @@ class ClaudeProvider extends AIProvider {
300
301
  ...this.extraEnv,
301
302
  PATH: `${BIN_DIR}:${process.env.PATH}`
302
303
  },
303
- shell: this.useShell
304
+ shell: this.useShell,
305
+ // In shell mode the immediate child is `/bin/sh -c '...claude...'`.
306
+ // Detaching makes the shell its own process-group leader so
307
+ // wireAbortToChild can `process.kill(-pid, SIGTERM)` and reap the
308
+ // CLI grandchild along with the shell. No effect when shell:false.
309
+ detached: this.useShell
304
310
  });
305
311
 
306
312
  const pid = claude.pid;
@@ -314,6 +320,13 @@ class ClaudeProvider extends AIProvider {
314
320
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
315
321
  }
316
322
 
323
+ // Wire AbortSignal -> SIGTERM. Tour/summary jobs run through the
324
+ // BackgroundQueue which threads its per-job signal here so a user
325
+ // "Cancel" click stops burning tokens on the upstream CLI call.
326
+ // Pass shell: this.useShell so the helper signals the whole process
327
+ // group (group-kill) instead of just the shell wrapper.
328
+ const abortWiring = wireAbortToChild(claude, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
329
+
317
330
  let stdout = '';
318
331
  let stderr = '';
319
332
  let timeoutId = null;
@@ -321,10 +334,16 @@ class ClaudeProvider extends AIProvider {
321
334
  let lineBuffer = ''; // Buffer for incomplete JSONL lines
322
335
  let lineCount = 0; // Count of JSONL events for progress tracking
323
336
 
337
+ // Centralize abort-listener cleanup in `settle` so it ALWAYS runs
338
+ // when this execute() returns — including when the timeout path
339
+ // settles before the child exits. Otherwise the abort listener
340
+ // outlives the call and leaks across the loop tour/summary
341
+ // generators run with a shared per-job signal.
324
342
  const settle = (fn, value) => {
325
343
  if (settled) return;
326
344
  settled = true;
327
345
  if (timeoutId) clearTimeout(timeoutId);
346
+ abortWiring.detach();
328
347
  fn(value);
329
348
  };
330
349
 
@@ -375,11 +394,24 @@ class ClaudeProvider extends AIProvider {
375
394
  claude.on('close', (code) => {
376
395
  if (settled) return; // Already settled by timeout or error
377
396
 
397
+ // Note: abort listener detach is centralized in `settle` so it
398
+ // runs even when the timeout path settled first.
399
+
378
400
  // Flush any remaining stream parser buffer
379
401
  if (streamParser) {
380
402
  streamParser.flush();
381
403
  }
382
404
 
405
+ // BackgroundQueue-driven cancellation: the user clicked "Cancel
406
+ // Tour"/"Cancel Summaries", which aborted our signal and we sent
407
+ // SIGTERM via wireAbortToChild. Surface it as an AbortError so
408
+ // upstream callers can distinguish "user cancel" from real failure.
409
+ if (abortWiring.cancelled()) {
410
+ logger.info(`${levelPrefix} Claude CLI terminated by user cancel (exit code ${code})`);
411
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
412
+ return;
413
+ }
414
+
383
415
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
384
416
  const isCancellationCode = code === 143 || code === 137;
385
417
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -410,13 +442,23 @@ class ClaudeProvider extends AIProvider {
410
442
  const parsed = this.parseClaudeResponse(stdout, level, levelPrefix);
411
443
  if (parsed.success) {
412
444
  logger.success(`${levelPrefix} Successfully parsed JSON response`);
413
- // Dump the parsed data for debugging
414
- const dataPreview = JSON.stringify(parsed.data, null, 2);
415
- logger.debug(`${levelPrefix} [parsed_data] ${dataPreview.substring(0, 3000)}${dataPreview.length > 3000 ? '...' : ''}`);
445
+ // Dump the parsed data for debugging.
446
+ // Skip for summary calls — they run per-file and the dump is per-call
447
+ // noise. The `[response]` line below already gives a useful one-liner.
448
+ const isSummaryCall = typeof levelPrefix === 'string' && levelPrefix.startsWith('[Summary');
449
+ if (!isSummaryCall) {
450
+ const dataPreview = JSON.stringify(parsed.data, null, 2);
451
+ logger.debug(`${levelPrefix} [parsed_data] ${dataPreview.substring(0, 3000)}${dataPreview.length > 3000 ? '...' : ''}`);
452
+ }
416
453
  // Log suggestion count if present
417
454
  if (parsed.data?.suggestions) {
418
455
  const count = Array.isArray(parsed.data.suggestions) ? parsed.data.suggestions.length : 0;
419
456
  logger.info(`${levelPrefix} [response] ${count} suggestions in parsed response`);
457
+ } else if (isSummaryCall && Array.isArray(parsed.data?.summaries)) {
458
+ const total = parsed.data.summaries.length;
459
+ const withText = parsed.data.summaries.filter((s) => s && typeof s.summary === 'string' && s.summary.length > 0).length;
460
+ const skipped = parsed.data.summaries.filter((s) => s && s.summary === null).length;
461
+ logger.info(`${levelPrefix} [response] ${total} summaries (${withText} with text, ${skipped} null)`);
420
462
  }
421
463
  settle(resolve, parsed.data);
422
464
  } else {
@@ -451,6 +493,7 @@ class ClaudeProvider extends AIProvider {
451
493
 
452
494
  // Handle errors
453
495
  claude.on('error', (error) => {
496
+ // Detach happens inside `settle` below.
454
497
  if (error.code === 'ENOENT') {
455
498
  logger.error(`${levelPrefix} Claude CLI not found. Please ensure Claude CLI is installed.`);
456
499
  settle(reject, new Error(`${levelPrefix} Claude CLI not found. ${ClaudeProvider.getInstallInstructions()}`));
@@ -756,7 +799,7 @@ class ClaudeProvider extends AIProvider {
756
799
  if (textContent) {
757
800
  logger.debug(`${levelPrefix} Extracted ${textContent.length} chars of text content from JSONL`);
758
801
  // Try to extract JSON from the accumulated text content
759
- const extracted = extractJSON(textContent, level);
802
+ const extracted = extractJSON(textContent, level, levelPrefix);
760
803
  if (extracted.success) {
761
804
  return extracted;
762
805
  }
@@ -774,7 +817,7 @@ class ClaudeProvider extends AIProvider {
774
817
 
775
818
  } catch (parseError) {
776
819
  // stdout might not be valid JSONL at all, try extracting JSON from it
777
- const extracted = extractJSON(stdout, level);
820
+ const extracted = extractJSON(stdout, level, levelPrefix);
778
821
  if (extracted.success) {
779
822
  return extracted;
780
823
  }
@@ -13,6 +13,7 @@ const logger = require('../utils/logger');
13
13
  const { extractJSON } = require('../utils/json-extractor');
14
14
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
15
15
  const { StreamParser, parseCodexLine } = require('./stream-parser');
16
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
16
17
 
17
18
  // Directory containing bin scripts (git-diff-lines, etc.)
18
19
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -242,7 +243,7 @@ class CodexProvider extends AIProvider {
242
243
  */
243
244
  async execute(prompt, options = {}) {
244
245
  return new Promise((resolve, reject) => {
245
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
246
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
246
247
 
247
248
  const levelPrefix = logPrefix || `[Level ${level}]`;
248
249
  logger.info(`${levelPrefix} Executing Codex CLI...`);
@@ -255,7 +256,10 @@ class CodexProvider extends AIProvider {
255
256
  ...this.extraEnv,
256
257
  PATH: `${BIN_DIR}:${process.env.PATH}`
257
258
  },
258
- shell: this.useShell
259
+ shell: this.useShell,
260
+ // Detach in shell mode so wireAbortToChild can group-kill via
261
+ // process.kill(-pid). See claude-provider for the rationale.
262
+ detached: this.useShell
259
263
  });
260
264
 
261
265
  const pid = codex.pid;
@@ -267,6 +271,10 @@ class CodexProvider extends AIProvider {
267
271
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
268
272
  }
269
273
 
274
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
275
+ // shell flag triggers group-kill so the CLI grandchild dies with the shell.
276
+ const abortWiring = wireAbortToChild(codex, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
277
+
270
278
  let stdout = '';
271
279
  let stderr = '';
272
280
  let timeoutId = null;
@@ -274,10 +282,15 @@ class CodexProvider extends AIProvider {
274
282
  let lineBuffer = ''; // Buffer for incomplete JSONL lines
275
283
  let lineCount = 0; // Count of JSONL events for progress tracking
276
284
 
285
+ // Centralize detach in `settle` so the abort listener is removed
286
+ // regardless of which exit path (close/timeout/error) wins. Avoids
287
+ // leaking a listener on the per-job AbortSignal that tour/summary
288
+ // generators reuse across many provider.execute() calls.
277
289
  const settle = (fn, value) => {
278
290
  if (settled) return;
279
291
  settled = true;
280
292
  if (timeoutId) clearTimeout(timeoutId);
293
+ abortWiring.detach();
281
294
  fn(value);
282
295
  };
283
296
 
@@ -328,11 +341,20 @@ class CodexProvider extends AIProvider {
328
341
  codex.on('close', (code) => {
329
342
  if (settled) return; // Already settled by timeout or error
330
343
 
344
+ // Detach is centralized in `settle`.
345
+
331
346
  // Flush any remaining stream parser buffer
332
347
  if (streamParser) {
333
348
  streamParser.flush();
334
349
  }
335
350
 
351
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
352
+ if (abortWiring.cancelled()) {
353
+ logger.info(`${levelPrefix} Codex CLI terminated by user cancel (exit code ${code})`);
354
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
355
+ return;
356
+ }
357
+
336
358
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
337
359
  const isCancellationCode = code === 143 || code === 137;
338
360
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -407,6 +429,7 @@ class CodexProvider extends AIProvider {
407
429
 
408
430
  // Handle errors
409
431
  codex.on('error', (error) => {
432
+ // Detach happens inside `settle`.
410
433
  if (error.code === 'ENOENT') {
411
434
  logger.error(`${levelPrefix} Codex CLI not found. Please ensure Codex CLI is installed.`);
412
435
  settle(reject, new Error(`${levelPrefix} Codex CLI not found. ${CodexProvider.getInstallInstructions()}`));
@@ -510,7 +533,7 @@ class CodexProvider extends AIProvider {
510
533
  // The accumulated agent_message text contains the AI's response
511
534
  // Try to extract JSON from it (the AI was asked to output JSON)
512
535
  logger.debug(`${levelPrefix} Extracted ${agentMessageText.length} chars of agent message text from JSONL`);
513
- const extracted = extractJSON(agentMessageText, level);
536
+ const extracted = extractJSON(agentMessageText, level, levelPrefix);
514
537
  if (extracted.success) {
515
538
  return extracted;
516
539
  }
@@ -522,12 +545,12 @@ class CodexProvider extends AIProvider {
522
545
  }
523
546
 
524
547
  // No agent message found, try extracting JSON directly from stdout
525
- const extracted = extractJSON(stdout, level);
548
+ const extracted = extractJSON(stdout, level, levelPrefix);
526
549
  return extracted;
527
550
 
528
551
  } catch (parseError) {
529
552
  // stdout might not be valid JSONL at all, try extracting JSON from it
530
- const extracted = extractJSON(stdout, level);
553
+ const extracted = extractJSON(stdout, level, levelPrefix);
531
554
  if (extracted.success) {
532
555
  return extracted;
533
556
  }
@@ -12,6 +12,7 @@ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
12
12
  const logger = require('../utils/logger');
13
13
  const { extractJSON } = require('../utils/json-extractor');
14
14
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
15
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
15
16
 
16
17
  // Directory containing bin scripts (git-diff-lines, etc.)
17
18
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -220,7 +221,7 @@ class CopilotProvider extends AIProvider {
220
221
  return new Promise((resolve, reject) => {
221
222
  // Note: Copilot does not support streaming — output is plain text returned on process exit, not JSONL.
222
223
  // onStreamEvent is therefore not destructured here (no StreamParser integration).
223
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, logPrefix } = options;
224
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, logPrefix, abortSignal } = options;
224
225
 
225
226
  const levelPrefix = logPrefix || `[Level ${level}]`;
226
227
  logger.info(`${levelPrefix} Executing Copilot CLI...`);
@@ -249,7 +250,9 @@ class CopilotProvider extends AIProvider {
249
250
  ...this.extraEnv,
250
251
  PATH: `${BIN_DIR}:${process.env.PATH}`
251
252
  },
252
- shell: this.useShell
253
+ shell: this.useShell,
254
+ // Detach in shell mode so group-kill can reap the CLI grandchild.
255
+ detached: this.useShell
253
256
  });
254
257
 
255
258
  const pid = copilot.pid;
@@ -261,15 +264,21 @@ class CopilotProvider extends AIProvider {
261
264
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
262
265
  }
263
266
 
267
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
268
+ const abortWiring = wireAbortToChild(copilot, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
269
+
264
270
  let stdout = '';
265
271
  let stderr = '';
266
272
  let timeoutId = null;
267
273
  let settled = false; // Guard against multiple resolve/reject calls
268
274
 
275
+ // Detach centralized in settle so timeout-then-close cannot leak
276
+ // an abort listener on the per-job AbortSignal.
269
277
  const settle = (fn, value) => {
270
278
  if (settled) return;
271
279
  settled = true;
272
280
  if (timeoutId) clearTimeout(timeoutId);
281
+ abortWiring.detach();
273
282
  fn(value);
274
283
  };
275
284
 
@@ -296,6 +305,15 @@ class CopilotProvider extends AIProvider {
296
305
  copilot.on('close', (code) => {
297
306
  if (settled) return; // Already settled by timeout or error
298
307
 
308
+ // Detach is centralized in `settle`.
309
+
310
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
311
+ if (abortWiring.cancelled()) {
312
+ logger.info(`${levelPrefix} Copilot CLI terminated by user cancel (exit code ${code})`);
313
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
314
+ return;
315
+ }
316
+
299
317
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
300
318
  const isCancellationCode = code === 143 || code === 137;
301
319
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -320,7 +338,7 @@ class CopilotProvider extends AIProvider {
320
338
  }
321
339
 
322
340
  // Extract JSON from the response
323
- const extracted = extractJSON(stdout, level);
341
+ const extracted = extractJSON(stdout, level, levelPrefix);
324
342
  if (extracted.success) {
325
343
  logger.success(`${levelPrefix} Successfully parsed JSON response`);
326
344
  settle(resolve, extracted.data);
@@ -352,6 +370,7 @@ class CopilotProvider extends AIProvider {
352
370
 
353
371
  // Handle errors
354
372
  copilot.on('error', (error) => {
373
+ // Detach happens inside `settle`.
355
374
  if (error.code === 'ENOENT') {
356
375
  logger.error(`${levelPrefix} Copilot CLI not found. Please ensure Copilot CLI is installed.`);
357
376
  settle(reject, new Error(`${levelPrefix} Copilot CLI not found. ${CopilotProvider.getInstallInstructions()}`));