@in-the-loop-labs/pair-review 3.5.1 → 3.6.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
@@ -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()}`));
@@ -21,6 +21,7 @@ const logger = require('../utils/logger');
21
21
  const { extractJSON } = require('../utils/json-extractor');
22
22
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
23
23
  const { StreamParser, parseCursorAgentLine } = require('./stream-parser');
24
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
24
25
 
25
26
  // Directory containing bin scripts (git-diff-lines, etc.)
26
27
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -262,7 +263,7 @@ class CursorAgentProvider extends AIProvider {
262
263
  */
263
264
  async execute(prompt, options = {}) {
264
265
  return new Promise((resolve, reject) => {
265
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
266
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
266
267
 
267
268
  const levelPrefix = logPrefix || `[Level ${level}]`;
268
269
  logger.info(`${levelPrefix} Executing Cursor Agent CLI...`);
@@ -275,7 +276,8 @@ class CursorAgentProvider extends AIProvider {
275
276
  ...this.extraEnv,
276
277
  PATH: `${BIN_DIR}:${process.env.PATH}`
277
278
  },
278
- shell: this.useShell
279
+ shell: this.useShell,
280
+ detached: this.useShell
279
281
  });
280
282
 
281
283
  const pid = agent.pid;
@@ -287,6 +289,9 @@ class CursorAgentProvider extends AIProvider {
287
289
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
288
290
  }
289
291
 
292
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
293
+ const abortWiring = wireAbortToChild(agent, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
294
+
290
295
  let stdout = '';
291
296
  let stderr = '';
292
297
  let timeoutId = null;
@@ -298,6 +303,7 @@ class CursorAgentProvider extends AIProvider {
298
303
  if (settled) return;
299
304
  settled = true;
300
305
  if (timeoutId) clearTimeout(timeoutId);
306
+ abortWiring.detach();
301
307
  fn(value);
302
308
  };
303
309
 
@@ -348,11 +354,20 @@ class CursorAgentProvider extends AIProvider {
348
354
  agent.on('close', (code) => {
349
355
  if (settled) return; // Already settled by timeout or error
350
356
 
357
+ // Detach is centralized in `settle`.
358
+
351
359
  // Flush any remaining stream parser buffer
352
360
  if (streamParser) {
353
361
  streamParser.flush();
354
362
  }
355
363
 
364
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
365
+ if (abortWiring.cancelled()) {
366
+ logger.info(`${levelPrefix} Cursor Agent CLI terminated by user cancel (exit code ${code})`);
367
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
368
+ return;
369
+ }
370
+
356
371
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
357
372
  const isCancellationCode = code === 143 || code === 137;
358
373
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -431,6 +446,7 @@ class CursorAgentProvider extends AIProvider {
431
446
 
432
447
  // Handle errors
433
448
  agent.on('error', (error) => {
449
+ // Detach happens inside `settle`.
434
450
  if (error.code === 'ENOENT') {
435
451
  logger.error(`${levelPrefix} Cursor Agent CLI not found. Please ensure Cursor Agent CLI is installed.`);
436
452
  settle(reject, new Error(`${levelPrefix} Cursor Agent CLI not found. ${CursorAgentProvider.getInstallInstructions()}`));
@@ -529,7 +545,7 @@ class CursorAgentProvider extends AIProvider {
529
545
  // Primary: try to extract JSON from accumulated assistant text
530
546
  if (assistantText) {
531
547
  logger.debug(`${levelPrefix} Extracted ${assistantText.length} chars of assistant text from JSONL`);
532
- const extracted = extractJSON(assistantText, level);
548
+ const extracted = extractJSON(assistantText, level, levelPrefix);
533
549
  if (extracted.success) {
534
550
  return extracted;
535
551
  }
@@ -540,7 +556,7 @@ class CursorAgentProvider extends AIProvider {
540
556
  // Fallback: try extracting JSON from the result event's text
541
557
  if (resultText) {
542
558
  logger.debug(`${levelPrefix} Trying result text: ${resultText.length} chars`);
543
- const extracted = extractJSON(resultText, level);
559
+ const extracted = extractJSON(resultText, level, levelPrefix);
544
560
  if (extracted.success) {
545
561
  return extracted;
546
562
  }
@@ -550,7 +566,7 @@ class CursorAgentProvider extends AIProvider {
550
566
 
551
567
  // Last resort: try extracting JSON directly from raw stdout
552
568
  if (!assistantText && !resultText) {
553
- const extracted = extractJSON(stdout, level);
569
+ const extracted = extractJSON(stdout, level, levelPrefix);
554
570
  return extracted;
555
571
  }
556
572
 
@@ -560,7 +576,7 @@ class CursorAgentProvider extends AIProvider {
560
576
 
561
577
  } catch (parseError) {
562
578
  // stdout might not be valid JSONL at all, try extracting JSON from it
563
- const extracted = extractJSON(stdout, level);
579
+ const extracted = extractJSON(stdout, level, levelPrefix);
564
580
  if (extracted.success) {
565
581
  return extracted;
566
582
  }
@@ -396,30 +396,15 @@ function createExecutableProviderClass(id, config) {
396
396
 
397
397
  // Find a mapping provider: prefer the user's configured default, fall back to
398
398
  // any registered non-executable provider. Never hardcode a specific provider.
399
- let mappingProviderId = null;
400
-
401
- // Try the user's configured default provider first
399
+ let preferredId = null;
402
400
  try {
403
401
  const config = await configModule.loadConfig();
404
- const defaultId = configModule.getDefaultProvider(config);
405
- const defaultClass = providerModule.getProviderClass(defaultId);
406
- if (defaultClass && !defaultClass.isExecutable) {
407
- mappingProviderId = defaultId;
408
- }
402
+ preferredId = configModule.getDefaultProvider(config);
409
403
  } catch {
410
- // Config or provider not available — fall through to fallback
404
+ // Config not available — fall through to fallback
411
405
  }
412
406
 
413
- if (!mappingProviderId) {
414
- // Fall back to any registered non-executable provider
415
- for (const pid of providerModule.getRegisteredProviderIds()) {
416
- const pClass = providerModule.getProviderClass(pid);
417
- if (pClass && !pClass.isExecutable) {
418
- mappingProviderId = pid;
419
- break;
420
- }
421
- }
422
- }
407
+ const mappingProviderId = providerModule.resolveNonExecutableProviderId(preferredId);
423
408
 
424
409
  if (!mappingProviderId) {
425
410
  throw new Error(`[${id}] No mapping provider available. Need at least one non-executable provider (e.g., claude) registered.`);
@@ -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, parseGeminiLine } = 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');
@@ -190,7 +191,7 @@ class GeminiProvider extends AIProvider {
190
191
  */
191
192
  async execute(prompt, options = {}) {
192
193
  return new Promise((resolve, reject) => {
193
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
194
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
194
195
 
195
196
  const levelPrefix = logPrefix || `[Level ${level}]`;
196
197
  logger.info(`${levelPrefix} Executing Gemini CLI...`);
@@ -203,7 +204,8 @@ class GeminiProvider extends AIProvider {
203
204
  ...this.extraEnv,
204
205
  PATH: `${BIN_DIR}:${process.env.PATH}`
205
206
  },
206
- shell: this.useShell
207
+ shell: this.useShell,
208
+ detached: this.useShell
207
209
  });
208
210
 
209
211
  const pid = gemini.pid;
@@ -215,6 +217,9 @@ class GeminiProvider extends AIProvider {
215
217
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
216
218
  }
217
219
 
220
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
221
+ const abortWiring = wireAbortToChild(gemini, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
222
+
218
223
  let stdout = '';
219
224
  let stderr = '';
220
225
  let timeoutId = null;
@@ -226,6 +231,7 @@ class GeminiProvider extends AIProvider {
226
231
  if (settled) return;
227
232
  settled = true;
228
233
  if (timeoutId) clearTimeout(timeoutId);
234
+ abortWiring.detach();
229
235
  fn(value);
230
236
  };
231
237
 
@@ -276,11 +282,21 @@ class GeminiProvider extends AIProvider {
276
282
  gemini.on('close', (code) => {
277
283
  if (settled) return; // Already settled by timeout or error
278
284
 
285
+ // Detach is centralized in `settle`.
286
+
279
287
  // Flush any remaining stream parser buffer
280
288
  if (streamParser) {
281
289
  streamParser.flush();
282
290
  }
283
291
 
292
+ // BackgroundQueue-driven cancellation — see claude-provider for
293
+ // the rationale; pattern is mirrored here.
294
+ if (abortWiring.cancelled()) {
295
+ logger.info(`${levelPrefix} Gemini CLI terminated by user cancel (exit code ${code})`);
296
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
297
+ return;
298
+ }
299
+
284
300
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
285
301
  const isCancellationCode = code === 143 || code === 137;
286
302
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -355,6 +371,7 @@ class GeminiProvider extends AIProvider {
355
371
 
356
372
  // Handle errors
357
373
  gemini.on('error', (error) => {
374
+ // Detach happens inside `settle`.
358
375
  if (error.code === 'ENOENT') {
359
376
  logger.error(`${levelPrefix} Gemini CLI not found. Please ensure Gemini CLI is installed.`);
360
377
  settle(reject, new Error(`${levelPrefix} Gemini CLI not found. ${GeminiProvider.getInstallInstructions()}`));
@@ -429,7 +446,7 @@ class GeminiProvider extends AIProvider {
429
446
  // The accumulated assistant text contains the AI's response
430
447
  // Try to extract JSON from it (the AI was asked to output JSON)
431
448
  logger.debug(`${levelPrefix} Extracted ${assistantText.length} chars of assistant message text from JSONL`);
432
- const extracted = extractJSON(assistantText, level);
449
+ const extracted = extractJSON(assistantText, level, levelPrefix);
433
450
  if (extracted.success) {
434
451
  return extracted;
435
452
  }
@@ -441,12 +458,12 @@ class GeminiProvider extends AIProvider {
441
458
  }
442
459
 
443
460
  // No assistant message found, try extracting JSON directly from stdout
444
- const extracted = extractJSON(stdout, level);
461
+ const extracted = extractJSON(stdout, level, levelPrefix);
445
462
  return extracted;
446
463
 
447
464
  } catch (parseError) {
448
465
  // stdout might not be valid JSONL at all, try extracting JSON from it
449
- const extracted = extractJSON(stdout, level);
466
+ const extracted = extractJSON(stdout, level, levelPrefix);
450
467
  if (extracted.success) {
451
468
  return extracted;
452
469
  }