@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
@@ -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
  }
@@ -0,0 +1,161 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * @typedef {Object} Hunk
7
+ * @property {string} header - Hunk header line, e.g. "@@ -10,5 +10,7 @@".
8
+ * @property {string[]} lines - Diff lines including their leading marker
9
+ * ('+', '-', ' ', or the literal '\' marker).
10
+ */
11
+
12
+ const LOCKFILE_BASENAMES = new Set([
13
+ 'package-lock.json',
14
+ 'pnpm-lock.yaml',
15
+ 'yarn.lock',
16
+ 'Cargo.lock',
17
+ 'Pipfile.lock',
18
+ 'poetry.lock',
19
+ 'composer.lock',
20
+ 'go.sum'
21
+ ]);
22
+
23
+ const JS_TS_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
24
+ const PYTHON_EXTENSIONS = new Set(['.py']);
25
+
26
+ const JS_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b|(?:const|let|var)\s+\w+\s*=\s*require\()/;
27
+ const PY_IMPORT_PATTERN = /^(?:import\b|from\s+\S+\s+import\b)/;
28
+ const PACKAGE_JSON_VERSION_PATTERN = /^"([^"]+)"\s*:\s*"[~^>=<]*\d[\w.\-+*]*"\,?\s*$/;
29
+
30
+ /**
31
+ * SHA-256 hex of `${filePath}\n${hunkContent}`.
32
+ * @param {string} filePath
33
+ * @param {string} hunkContent
34
+ * @returns {string}
35
+ */
36
+ function hashHunk(filePath, hunkContent) {
37
+ return crypto.createHash('sha256').update(`${filePath}\n${hunkContent}`).digest('hex');
38
+ }
39
+
40
+ function getExtension(filePath) {
41
+ const slash = filePath.lastIndexOf('/');
42
+ const base = slash === -1 ? filePath : filePath.slice(slash + 1);
43
+ const dot = base.lastIndexOf('.');
44
+ if (dot <= 0) return '';
45
+ return base.slice(dot).toLowerCase();
46
+ }
47
+
48
+ function getBasename(filePath) {
49
+ const slash = filePath.lastIndexOf('/');
50
+ return slash === -1 ? filePath : filePath.slice(slash + 1);
51
+ }
52
+
53
+ function classifyLines(lines) {
54
+ const added = [];
55
+ const removed = [];
56
+ for (const line of lines) {
57
+ if (line.startsWith('\\')) continue;
58
+ if (line.startsWith('+')) added.push(line.slice(1));
59
+ else if (line.startsWith('-')) removed.push(line.slice(1));
60
+ }
61
+ return { added, removed };
62
+ }
63
+
64
+ function isImportOnlyReorder(added, removed, ext) {
65
+ let pattern;
66
+ if (JS_TS_EXTENSIONS.has(ext)) pattern = JS_IMPORT_PATTERN;
67
+ else if (PYTHON_EXTENSIONS.has(ext)) pattern = PY_IMPORT_PATTERN;
68
+ else return false;
69
+
70
+ if (added.length === 0 && removed.length === 0) return false;
71
+
72
+ const addedTrimmed = added.map((l) => l.trim());
73
+ const removedTrimmed = removed.map((l) => l.trim());
74
+
75
+ for (const line of addedTrimmed) {
76
+ if (!pattern.test(line)) return false;
77
+ }
78
+ for (const line of removedTrimmed) {
79
+ if (!pattern.test(line)) return false;
80
+ }
81
+
82
+ if (addedTrimmed.length !== removedTrimmed.length) return false;
83
+ const a = [...addedTrimmed].sort();
84
+ const r = [...removedTrimmed].sort();
85
+ for (let i = 0; i < a.length; i++) {
86
+ if (a[i] !== r[i]) return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function extractPackageJsonVersionKey(line) {
92
+ const match = PACKAGE_JSON_VERSION_PATTERN.exec(line);
93
+ return match ? match[1] : null;
94
+ }
95
+
96
+ function isVersionBumpChange(added, removed, basename) {
97
+ if (basename !== 'package.json' && !LOCKFILE_BASENAMES.has(basename)) return false;
98
+ if (added.length === 0 && removed.length === 0) return false;
99
+
100
+ if (LOCKFILE_BASENAMES.has(basename)) return true;
101
+
102
+ const addedKeys = [];
103
+ for (const line of added) {
104
+ const key = extractPackageJsonVersionKey(line.trim());
105
+ if (key === null) return false;
106
+ addedKeys.push(key);
107
+ }
108
+ const removedKeys = [];
109
+ for (const line of removed) {
110
+ const key = extractPackageJsonVersionKey(line.trim());
111
+ if (key === null) return false;
112
+ removedKeys.push(key);
113
+ }
114
+
115
+ if (addedKeys.length !== removedKeys.length) return false;
116
+ const a = [...addedKeys].sort();
117
+ const r = [...removedKeys].sort();
118
+ for (let i = 0; i < a.length; i++) {
119
+ if (a[i] !== r[i]) return false;
120
+ }
121
+ return true;
122
+ }
123
+
124
+ /**
125
+ * Classify a hunk as trivial under one of several heuristics.
126
+ *
127
+ * Callers that need generated-file detection should pass
128
+ * isGeneratedFile: parser.isGenerated.bind(parser)
129
+ * where `parser` is the result of
130
+ * await getGeneratedFilePatterns(worktreePath)
131
+ * from src/git/gitattributes.js. When `isGeneratedFile` is omitted, the
132
+ * generated-file rule is skipped silently.
133
+ * @param {Hunk} hunk
134
+ * @param {string} filePath
135
+ * @param {{ isGeneratedFile?: (filePath: string) => boolean }} [options]
136
+ * @returns {{ trivial: boolean, reason?: 'imports'|'version_bump'|'generated' }}
137
+ */
138
+ function isTrivialHunk(hunk, filePath, options) {
139
+ const opts = options || {};
140
+
141
+ if (typeof opts.isGeneratedFile === 'function' && opts.isGeneratedFile(filePath) === true) {
142
+ return { trivial: true, reason: 'generated' };
143
+ }
144
+
145
+ const lines = hunk && Array.isArray(hunk.lines) ? hunk.lines : [];
146
+ const { added, removed } = classifyLines(lines);
147
+
148
+ const ext = getExtension(filePath);
149
+ if (isImportOnlyReorder(added, removed, ext)) {
150
+ return { trivial: true, reason: 'imports' };
151
+ }
152
+
153
+ const basename = getBasename(filePath);
154
+ if (isVersionBumpChange(added, removed, basename)) {
155
+ return { trivial: true, reason: 'version_bump' };
156
+ }
157
+
158
+ return { trivial: false };
159
+ }
160
+
161
+ module.exports = { hashHunk, isTrivialHunk };
package/src/ai/index.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  registerProvider,
14
14
  getProviderClass,
15
15
  getRegisteredProviderIds,
16
+ resolveNonExecutableProviderId,
16
17
  getAllProvidersInfo,
17
18
  createProvider,
18
19
  testProviderAvailability,
@@ -60,6 +61,7 @@ module.exports = {
60
61
  registerProvider,
61
62
  getProviderClass,
62
63
  getRegisteredProviderIds,
64
+ resolveNonExecutableProviderId,
63
65
  getAllProvidersInfo,
64
66
 
65
67
  // Factory
@@ -18,6 +18,7 @@ const logger = require('../utils/logger');
18
18
  const { extractJSON } = require('../utils/json-extractor');
19
19
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
20
20
  const { StreamParser, parseOpenCodeLine } = require('./stream-parser');
21
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
21
22
 
22
23
  // Directory containing bin scripts (git-diff-lines, etc.)
23
24
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -102,7 +103,7 @@ class OpenCodeProvider extends AIProvider {
102
103
  */
103
104
  async execute(prompt, options = {}) {
104
105
  return new Promise((resolve, reject) => {
105
- const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
106
+ const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
106
107
 
107
108
  const levelPrefix = logPrefix || `[Level ${level}]`;
108
109
  logger.info(`${levelPrefix} Executing OpenCode CLI...`);
@@ -128,7 +129,8 @@ class OpenCodeProvider extends AIProvider {
128
129
  ...this.extraEnv,
129
130
  PATH: `${BIN_DIR}:${process.env.PATH}`
130
131
  },
131
- shell: this.useShell
132
+ shell: this.useShell,
133
+ detached: this.useShell
132
134
  });
133
135
 
134
136
  const pid = opencode.pid;
@@ -140,6 +142,9 @@ class OpenCodeProvider extends AIProvider {
140
142
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
141
143
  }
142
144
 
145
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
146
+ const abortWiring = wireAbortToChild(opencode, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
147
+
143
148
  let stdout = '';
144
149
  let stderr = '';
145
150
  let timeoutId = null;
@@ -151,6 +156,7 @@ class OpenCodeProvider extends AIProvider {
151
156
  if (settled) return;
152
157
  settled = true;
153
158
  if (timeoutId) clearTimeout(timeoutId);
159
+ abortWiring.detach();
154
160
  fn(value);
155
161
  };
156
162
 
@@ -201,11 +207,20 @@ class OpenCodeProvider extends AIProvider {
201
207
  opencode.on('close', (code) => {
202
208
  if (settled) return; // Already settled by timeout or error
203
209
 
210
+ // Detach is centralized in `settle`.
211
+
204
212
  // Flush any remaining stream parser buffer
205
213
  if (streamParser) {
206
214
  streamParser.flush();
207
215
  }
208
216
 
217
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
218
+ if (abortWiring.cancelled()) {
219
+ logger.info(`${levelPrefix} OpenCode CLI terminated by user cancel (exit code ${code})`);
220
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
221
+ return;
222
+ }
223
+
209
224
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
210
225
  const isCancellationCode = code === 143 || code === 137;
211
226
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -281,6 +296,7 @@ class OpenCodeProvider extends AIProvider {
281
296
 
282
297
  // Handle errors
283
298
  opencode.on('error', (error) => {
299
+ // Detach happens inside `settle`.
284
300
  if (error.code === 'ENOENT') {
285
301
  logger.error(`${levelPrefix} OpenCode CLI not found. Please ensure OpenCode CLI is installed.`);
286
302
  settle(reject, new Error(`${levelPrefix} OpenCode CLI not found. ${OpenCodeProvider.getInstallInstructions()}`));
@@ -491,7 +507,7 @@ class OpenCodeProvider extends AIProvider {
491
507
 
492
508
  if (textContent) {
493
509
  // Try to extract JSON from the accumulated text content
494
- const extracted = extractJSON(textContent, level);
510
+ const extracted = extractJSON(textContent, level, levelPrefix);
495
511
  if (extracted.success) {
496
512
  return extracted;
497
513
  }
@@ -503,12 +519,12 @@ class OpenCodeProvider extends AIProvider {
503
519
  }
504
520
 
505
521
  // No text content found, try extracting JSON directly from stdout
506
- const extracted = extractJSON(stdout, level);
522
+ const extracted = extractJSON(stdout, level, levelPrefix);
507
523
  return extracted;
508
524
 
509
525
  } catch (parseError) {
510
526
  // stdout might not be valid JSONL at all, try extracting JSON from it
511
- const extracted = extractJSON(stdout, level);
527
+ const extracted = extractJSON(stdout, level, levelPrefix);
512
528
  if (extracted.success) {
513
529
  return extracted;
514
530
  }
@@ -26,6 +26,7 @@ const logger = require('../utils/logger');
26
26
  const { extractJSON } = require('../utils/json-extractor');
27
27
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
28
28
  const { createPiLineParser } = require('./stream-parser');
29
+ const { wireAbortToChild, makeAbortError } = require('./abort-signal-wiring');
29
30
 
30
31
  // Directory containing bin scripts (git-diff-lines, etc.)
31
32
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
@@ -430,7 +431,7 @@ function appendPiChunkToLineBuffer(state, chunk, levelPrefix) {
430
431
  */
431
432
  function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated = false }, level, levelPrefix) {
432
433
  if (textContent) {
433
- const extracted = extractJSON(textContent, level);
434
+ const extracted = extractJSON(textContent, level, levelPrefix);
434
435
  if (extracted.success) {
435
436
  return extracted;
436
437
  }
@@ -447,7 +448,7 @@ function finalizePiResponseParsing({ textContent, rawOutput, rawOutputTruncated
447
448
  };
448
449
  }
449
450
 
450
- return extractJSON(rawOutput, level);
451
+ return extractJSON(rawOutput, level, levelPrefix);
451
452
  }
452
453
 
453
454
  class PiProvider extends AIProvider {
@@ -579,7 +580,7 @@ class PiProvider extends AIProvider {
579
580
  */
580
581
  async execute(prompt, options = {}) {
581
582
  return new Promise((resolve, reject) => {
582
- const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix } = options;
583
+ const { cwd = process.cwd(), timeout = 900000, level = 'unknown', analysisId, registerProcess, onStreamEvent, logPrefix, abortSignal } = options;
583
584
 
584
585
  const levelPrefix = logPrefix || `[Level ${level}]`;
585
586
  logger.info(`${levelPrefix} Executing Pi CLI...`);
@@ -609,7 +610,8 @@ class PiProvider extends AIProvider {
609
610
  ...this.extraEnv,
610
611
  PATH: `${BIN_DIR}:${process.env.PATH}`
611
612
  },
612
- shell: this.useShell
613
+ shell: this.useShell,
614
+ detached: this.useShell
613
615
  });
614
616
 
615
617
  // Close stdin immediately — prompt is delivered via @file, but some
@@ -626,6 +628,9 @@ class PiProvider extends AIProvider {
626
628
  logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
627
629
  }
628
630
 
631
+ // Wire AbortSignal -> SIGTERM for tour/summary cancellation.
632
+ const abortWiring = wireAbortToChild(pi, abortSignal, { logPrefix: levelPrefix, shell: this.useShell });
633
+
629
634
  const stderrCapture = {
630
635
  head: '',
631
636
  tail: '',
@@ -652,6 +657,7 @@ class PiProvider extends AIProvider {
652
657
  if (settled) return;
653
658
  settled = true;
654
659
  if (timeoutId) clearTimeout(timeoutId);
660
+ abortWiring.detach();
655
661
  fn(value);
656
662
  };
657
663
 
@@ -711,6 +717,15 @@ class PiProvider extends AIProvider {
711
717
  cleanupTmpFile();
712
718
  if (settled) return; // Already settled by timeout or error
713
719
 
720
+ // Detach is centralized in `settle`.
721
+
722
+ // BackgroundQueue-driven cancellation — mirror of claude-provider.
723
+ if (abortWiring.cancelled()) {
724
+ logger.info(`${levelPrefix} Pi CLI terminated by user cancel (exit code ${code})`);
725
+ settle(reject, makeAbortError(`${levelPrefix} Cancelled by user`));
726
+ return;
727
+ }
728
+
714
729
  // Check for cancellation signals (SIGTERM=143, SIGKILL=137)
715
730
  const isCancellationCode = code === 143 || code === 137;
716
731
  if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
@@ -810,6 +825,7 @@ class PiProvider extends AIProvider {
810
825
  // Handle errors
811
826
  pi.on('error', (error) => {
812
827
  cleanupTmpFile();
828
+ // Detach happens inside `settle`.
813
829
  if (error.code === 'ENOENT') {
814
830
  logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
815
831
  settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
@@ -994,7 +1010,7 @@ class PiProvider extends AIProvider {
994
1010
 
995
1011
  } catch (parseError) {
996
1012
  // stdout might not be valid JSONL at all, try extracting JSON from it
997
- const extracted = extractJSON(stdout, level);
1013
+ const extracted = extractJSON(stdout, level, levelPrefix);
998
1014
  if (extracted.success) {
999
1015
  return extracted;
1000
1016
  }