@in-the-loop-labs/pair-review 3.5.2 → 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.
- package/package.json +15 -20
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/pr.css +603 -6
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- 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
|
-
|
|
415
|
-
|
|
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
|
}
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
404
|
+
// Config not available — fall through to fallback
|
|
411
405
|
}
|
|
412
406
|
|
|
413
|
-
|
|
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
|
}
|