@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
@@ -0,0 +1,77 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared modal-detection helpers.
4
+ *
5
+ * Used by KeyboardShortcuts and PRManager so keyboard handlers consistently
6
+ * defer to whatever modal/dialog is currently open. Single source of truth
7
+ * for both the selector list and the visibility check — keeping them in
8
+ * sync across consumers was the original motivation for extracting this.
9
+ *
10
+ * The help overlay (`#keyboard-shortcuts-help`) is intentionally excluded
11
+ * from the selectors so the shortcuts overlay itself doesn't suppress
12
+ * Escape handling for its own close button.
13
+ */
14
+
15
+ /**
16
+ * Selectors that identify "a modal is open". Anything matched here will be
17
+ * treated as a blocker for unrelated keyboard shortcuts.
18
+ * @type {string[]}
19
+ */
20
+ const MODAL_SELECTORS = [
21
+ '.modal-overlay:not(#keyboard-shortcuts-help)',
22
+ '.review-modal-overlay',
23
+ '.preview-modal-overlay',
24
+ '.confirm-dialog-overlay',
25
+ '.analysis-config-overlay',
26
+ '.ai-summary-modal-overlay',
27
+ '[role="dialog"]:not(#keyboard-shortcuts-help)'
28
+ ];
29
+
30
+ /**
31
+ * Return true when `element` is visually present — i.e. not hidden via
32
+ * `display`, `visibility`, or zero opacity. Mirrors the legacy
33
+ * KeyboardShortcuts.isElementVisible behavior so existing call sites keep
34
+ * the same semantics.
35
+ *
36
+ * @param {Element|null|undefined} element
37
+ * @returns {boolean}
38
+ */
39
+ function isElementVisible(element) {
40
+ if (!element) return false;
41
+ if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') {
42
+ return true;
43
+ }
44
+ const style = window.getComputedStyle(element);
45
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /**
52
+ * Return true when at least one of the registered modal selectors matches
53
+ * a visible element in the document.
54
+ *
55
+ * Excludes the keyboard-shortcuts help overlay so it doesn't block its own
56
+ * close behavior.
57
+ *
58
+ * @returns {boolean}
59
+ */
60
+ function isModalOpen() {
61
+ if (typeof document === 'undefined') return false;
62
+ for (const selector of MODAL_SELECTORS) {
63
+ const el = document.querySelector(selector);
64
+ if (el && isElementVisible(el)) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+
71
+ if (typeof window !== 'undefined') {
72
+ window.ModalDetection = { isModalOpen, isElementVisible, MODAL_SELECTORS };
73
+ }
74
+
75
+ if (typeof module !== 'undefined' && module.exports) {
76
+ module.exports = { isModalOpen, isElementVisible, MODAL_SELECTORS };
77
+ }
package/public/local.html CHANGED
@@ -448,6 +448,16 @@
448
448
  </svg>
449
449
  <span class="btn-text">Analyze</span>
450
450
  </button>
451
+ <button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
452
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
453
+ <path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
454
+ </svg>
455
+ </button>
456
+ <button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
457
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
458
+ <path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
459
+ </svg>
460
+ </button>
451
461
  <button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
452
462
  <svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
453
463
  <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
@@ -597,6 +607,9 @@
597
607
  <!-- Timestamp parsing utility -->
598
608
  <script src="/js/utils/time.js"></script>
599
609
 
610
+ <!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
611
+ <script src="/js/utils/modal-detection.js"></script>
612
+
600
613
  <!-- WebSocket client -->
601
614
  <script src="/js/ws-client.js"></script>
602
615
 
@@ -639,6 +652,10 @@
639
652
  <script src="/js/modules/analysis-history.js"></script>
640
653
  <script src="/js/modules/diff-context.js"></script>
641
654
  <script src="/js/modules/file-list-merger.js"></script>
655
+ <script src="/js/modules/hunk-summary-renderer.js"></script>
656
+ <script src="/js/modules/tour-renderer.js"></script>
657
+ <script src="/js/modules/cancel-background-job.js"></script>
658
+ <script src="/js/components/TourBar.js"></script>
642
659
 
643
660
  <!-- Chat Panel Component -->
644
661
  <script src="/js/components/ChatPanel.js"></script>
package/public/pr.html CHANGED
@@ -244,6 +244,16 @@
244
244
  </svg>
245
245
  <span class="btn-text">Analyze</span>
246
246
  </button>
247
+ <button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
248
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
249
+ <path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
250
+ </svg>
251
+ </button>
252
+ <button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
253
+ <svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
254
+ <path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
255
+ </svg>
256
+ </button>
247
257
  <button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
248
258
  <svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
249
259
  <path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
@@ -400,6 +410,9 @@
400
410
  <!-- Timestamp parsing utility -->
401
411
  <script src="/js/utils/time.js"></script>
402
412
 
413
+ <!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
414
+ <script src="/js/utils/modal-detection.js"></script>
415
+
403
416
  <!-- WebSocket client -->
404
417
  <script src="/js/ws-client.js"></script>
405
418
 
@@ -443,6 +456,10 @@
443
456
  <script src="/js/modules/analysis-history.js"></script>
444
457
  <script src="/js/modules/diff-context.js"></script>
445
458
  <script src="/js/modules/file-list-merger.js"></script>
459
+ <script src="/js/modules/hunk-summary-renderer.js"></script>
460
+ <script src="/js/modules/tour-renderer.js"></script>
461
+ <script src="/js/modules/cancel-background-job.js"></script>
462
+ <script src="/js/components/TourBar.js"></script>
446
463
 
447
464
  <!-- Chat Panel Component -->
448
465
  <script src="/js/components/ChatPanel.js"></script>
@@ -0,0 +1,130 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const { spawn } = require('child_process');
4
+ const logger = require('../utils/logger');
5
+
6
+ /**
7
+ * Attach an `AbortSignal` to a spawned child process so that aborting the
8
+ * signal kills the child with `SIGTERM`. Returns a cleanup function that
9
+ * detaches the abort listener — call it from the `close` / `error` /
10
+ * `settle` handler so the listener never outlives the process.
11
+ *
12
+ * Pattern: every provider that spawns an upstream CLI for tour/summary
13
+ * generation calls this once right after `spawn(...)`. The returned
14
+ * `cancelled` getter is included so the post-exit path can distinguish a
15
+ * user-initiated cancel (exit due to SIGTERM we sent) from a real failure.
16
+ *
17
+ * If `signal` is already aborted at the time of wiring, the child is
18
+ * killed immediately and `cancelled` is set to true. Callers should still
19
+ * check `cancelled` before treating the eventual exit as a "real" error.
20
+ *
21
+ * Shell-mode caveat: when the caller spawned with `shell: true`, the
22
+ * `child` we hold is the shell, not the underlying CLI. `child.kill()`
23
+ * only terminates the shell; the grandchild CLI keeps burning tokens.
24
+ * Pass `{ shell: true }` here so we signal the whole process group via
25
+ * `process.kill(-pid, 'SIGTERM')` instead. On Windows we fall back to
26
+ * `taskkill /T /F /PID`. Prefer `shell: false` invocation when an
27
+ * abortSignal is in play — fewer moving parts.
28
+ *
29
+ * @param {import('child_process').ChildProcess} child - Spawned process.
30
+ * @param {AbortSignal | null | undefined} signal - Signal to listen on.
31
+ * @param {Object} [opts]
32
+ * @param {string} [opts.logPrefix] - Log prefix for diagnostics.
33
+ * @param {boolean} [opts.shell=false] - True when the child was spawned
34
+ * with `shell: true`. Causes group-kill semantics so the grandchild CLI
35
+ * dies along with the shell wrapper.
36
+ * @returns {{cancelled: () => boolean, detach: () => void}}
37
+ */
38
+ function wireAbortToChild(child, signal, opts = {}) {
39
+ let cancelled = false;
40
+ if (!signal) {
41
+ return { cancelled: () => cancelled, detach: () => {} };
42
+ }
43
+ const prefix = opts.logPrefix || '';
44
+ const isShell = opts.shell === true;
45
+
46
+ const killChild = () => {
47
+ // `kill` / process group signaling returns false (or throws ESRCH) if
48
+ // the process is already gone, which is fine — we just need the side
49
+ // effect when it IS still alive.
50
+ if (isShell && child.pid && process.platform !== 'win32') {
51
+ // Group-kill the shell AND its CLI descendant. Requires the caller
52
+ // to have spawned with `detached: true` so the child became a
53
+ // process-group leader (`-pid` targets the group).
54
+ try {
55
+ process.kill(-child.pid, 'SIGTERM');
56
+ return;
57
+ } catch (err) {
58
+ if (err && err.code === 'ESRCH') {
59
+ // Group already gone — nothing to kill.
60
+ return;
61
+ }
62
+ // Fall through to single-process kill as a best effort.
63
+ logger.warn(
64
+ `${prefix} process.kill(-pid) failed (${err.message}); falling back to child.kill`
65
+ );
66
+ }
67
+ }
68
+ if (isShell && child.pid && process.platform === 'win32') {
69
+ // Windows has no process groups: spawn taskkill /T /F to wipe the
70
+ // tree rooted at our shell pid.
71
+ try {
72
+ spawn('taskkill', ['/T', '/F', '/PID', String(child.pid)], { stdio: 'ignore' })
73
+ .on('error', (err) => {
74
+ logger.warn(`${prefix} taskkill failed: ${err.message}`);
75
+ });
76
+ return;
77
+ } catch (err) {
78
+ logger.warn(
79
+ `${prefix} spawn(taskkill) failed (${err.message}); falling back to child.kill`
80
+ );
81
+ }
82
+ }
83
+ child.kill('SIGTERM');
84
+ };
85
+
86
+ const onAbort = () => {
87
+ cancelled = true;
88
+ try {
89
+ killChild();
90
+ } catch (err) {
91
+ logger.warn(`${prefix} child.kill on abort failed: ${err.message}`);
92
+ }
93
+ };
94
+
95
+ if (signal.aborted) {
96
+ // Pre-aborted: trigger the kill immediately. The eventual `close`
97
+ // handler will see `cancelled === true` and short-circuit.
98
+ onAbort();
99
+ } else {
100
+ signal.addEventListener('abort', onAbort, { once: true });
101
+ }
102
+
103
+ return {
104
+ cancelled: () => cancelled,
105
+ detach: () => {
106
+ try {
107
+ signal.removeEventListener('abort', onAbort);
108
+ } catch {
109
+ // Older AbortSignal polyfills may lack removeEventListener; safe to ignore.
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Build a standardized cancellation error. Providers should throw this
117
+ * (or reject with it) when they detect the abort wiring fired, so the
118
+ * BackgroundQueue's broadcast can mark the job as `cancelled: true`.
119
+ *
120
+ * @param {string} [message] - Human-readable context (defaults to 'cancelled').
121
+ * @returns {Error}
122
+ */
123
+ function makeAbortError(message) {
124
+ const err = new Error(message || 'cancelled');
125
+ err.name = 'AbortError';
126
+ err.isCancellation = true;
127
+ return err;
128
+ }
129
+
130
+ module.exports = { wireAbortToChild, makeAbortError };
@@ -0,0 +1,290 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const { broadcastReviewEvent } = require('../events/review-events');
4
+ const logger = require('../utils/logger');
5
+ const { makeAbortError } = require('./abort-signal-wiring');
6
+
7
+ const BACKGROUND_QUEUE_CONCURRENCY = 2;
8
+
9
+ const defaults = {
10
+ broadcast: broadcastReviewEvent,
11
+ };
12
+
13
+ /**
14
+ * Bounded-concurrency in-process queue with per-key dedup.
15
+ *
16
+ * Jobs are keyed by `${reviewId}:${jobType}`; concurrent enqueues
17
+ * for the same key share a single execution and a single promise.
18
+ *
19
+ * Cancellation: each enqueued job is associated with an `AbortController`.
20
+ * The worker thunk is invoked as `fn(signal)` so it can plumb the signal
21
+ * into downstream provider calls / `fetch` / `child_process.spawn`.
22
+ * Callers cancel via `cancel(reviewId, jobKey)`, which aborts the
23
+ * signal and removes the controller; the worker is expected to react
24
+ * to the abort and settle (typically by rejecting with an AbortError).
25
+ */
26
+ class BackgroundQueue {
27
+ /**
28
+ * @param {Object} [options]
29
+ * @param {number} [options.concurrency] - Max concurrent jobs.
30
+ * @param {Object} [options._deps] - Override dependencies (testing).
31
+ * @param {Function} [options._deps.broadcast] - Broadcast hook.
32
+ */
33
+ constructor(options = {}) {
34
+ const { concurrency = BACKGROUND_QUEUE_CONCURRENCY, _deps = {} } = options;
35
+ this.concurrency = concurrency;
36
+ this.active = 0;
37
+ this.queue = [];
38
+ this.inFlight = new Map();
39
+ // Per-key AbortController covers both queued and running jobs so a
40
+ // single lookup can resolve cancellation against either state.
41
+ this.controllers = new Map();
42
+ this._deps = { ...defaults, ..._deps };
43
+ }
44
+
45
+ /**
46
+ * Enqueue a job for execution.
47
+ *
48
+ * Dedup contract: if a job for the same `(reviewId, jobType)` key is
49
+ * already queued or running, this returns the existing promise without
50
+ * invoking `fn`. The duplicate `fn` is silently dropped.
51
+ *
52
+ * The thunk is called as `fn(signal)` where `signal` is the `AbortSignal`
53
+ * for this job. Workers that touch the network, spawn processes, or
54
+ * otherwise burn upstream resources should thread the signal through so
55
+ * cancellation actually frees those resources.
56
+ *
57
+ * @param {string|number} reviewId - Review identifier.
58
+ * @param {string} jobType - Job category (e.g. 'summaries', 'tour').
59
+ * @param {Function} fn - Thunk `(signal) => value|Promise<value>`.
60
+ * @returns {Promise} Resolves/rejects with the job result.
61
+ */
62
+ enqueue(reviewId, jobType, fn) {
63
+ const key = `${reviewId}:${jobType}`;
64
+ if (this.inFlight.has(key)) {
65
+ return this.inFlight.get(key);
66
+ }
67
+ let resolve;
68
+ let reject;
69
+ const p = new Promise((res, rej) => {
70
+ resolve = res;
71
+ reject = rej;
72
+ });
73
+ this.inFlight.set(key, p);
74
+ const controller = new AbortController();
75
+ this.controllers.set(key, controller);
76
+ this.queue.push({
77
+ key,
78
+ run: fn,
79
+ resolve,
80
+ reject,
81
+ reviewId,
82
+ jobType,
83
+ controller,
84
+ promise: p,
85
+ });
86
+ this._drain();
87
+ return p;
88
+ }
89
+
90
+ /**
91
+ * Cancel an in-flight or queued job. Aborts its `AbortSignal` so the
92
+ * worker can tear down upstream resources, then drops the controller.
93
+ *
94
+ * Matching is exact on `(reviewId, jobKey)`. For composite jobTypes
95
+ * like `summaries:${digest}`, callers may also pass a bare prefix
96
+ * (`summaries`) — this cancels ALL matching `summaries:*` jobs for the
97
+ * review. This is what the toolbar "Cancel Summaries" button needs:
98
+ * users don't know about digests, they just want the pulse to stop.
99
+ *
100
+ * @param {string|number} reviewId
101
+ * @param {string} jobKey - bare `jobType` (e.g. `tour`) or full key
102
+ * suffix (e.g. `summaries:abc123`).
103
+ * @returns {{cancelled: number}} number of jobs aborted.
104
+ */
105
+ cancel(reviewId, jobKey) {
106
+ if (jobKey === undefined || jobKey === null || jobKey === '') {
107
+ return { cancelled: 0 };
108
+ }
109
+ const exact = `${reviewId}:${jobKey}`;
110
+ const prefix = `${exact}:`;
111
+ let cancelled = 0;
112
+ // Snapshot keys before aborting — settling a worker mid-iteration would
113
+ // mutate this.controllers (via _settle).
114
+ const keys = Array.from(this.controllers.keys());
115
+ for (const key of keys) {
116
+ if (key !== exact && !key.startsWith(prefix)) continue;
117
+ const controller = this.controllers.get(key);
118
+ if (!controller) continue;
119
+ try {
120
+ controller.abort();
121
+ } catch (err) {
122
+ logger.warn(`BackgroundQueue controller.abort() failed for ${key}: ${err.message}`);
123
+ }
124
+ // Eagerly evict the cancelled key from the dedup/controller maps and
125
+ // splice any not-yet-started descriptors out of the queue. Without
126
+ // this, a follow-up enqueue() for the same key would hit the dedup
127
+ // guard and inherit the about-to-reject promise, and _drain() could
128
+ // hand the worker an already-aborted signal. _settle()'s deletes are
129
+ // identity-guarded, so when the cancelled worker eventually rejects
130
+ // it won't clobber a replacement job installed under the same key.
131
+ this._evictKey(key);
132
+ cancelled++;
133
+ }
134
+ return { cancelled };
135
+ }
136
+
137
+ /**
138
+ * Remove a key from the dedup/controller maps and reject any queued (not
139
+ * yet started) descriptor with an AbortError. Safe to call when the key
140
+ * has already been cleaned up — Map.delete and Array.splice both no-op.
141
+ *
142
+ * @param {string} key - Composite `${reviewId}:${jobType}` key.
143
+ * @private
144
+ */
145
+ _evictKey(key) {
146
+ // Splice queued descriptors and reject their promises so the dedup'd
147
+ // caller (if any) sees a clean cancellation rather than a hung promise.
148
+ for (let i = this.queue.length - 1; i >= 0; i--) {
149
+ if (this.queue[i].key !== key) continue;
150
+ const [descriptor] = this.queue.splice(i, 1);
151
+ try {
152
+ descriptor.reject(makeAbortError('Job cancelled before start'));
153
+ } catch (rejectErr) {
154
+ logger.warn(
155
+ `BackgroundQueue descriptor.reject failed for ${key}: ${rejectErr.message}`
156
+ );
157
+ }
158
+ }
159
+ this.inFlight.delete(key);
160
+ this.controllers.delete(key);
161
+ }
162
+
163
+ /** Start as many queued jobs as concurrency allows. */
164
+ _drain() {
165
+ while (this.active < this.concurrency && this.queue.length > 0) {
166
+ const descriptor = this.queue.shift();
167
+ this.active++;
168
+ Promise.resolve()
169
+ .then(() => descriptor.run(descriptor.controller.signal))
170
+ .then(
171
+ (result) => this._settle(descriptor, null, result),
172
+ (error) => this._settle(descriptor, error, undefined)
173
+ );
174
+ }
175
+ }
176
+
177
+ /** Finalize a job: free its key, broadcast, settle, and drain. */
178
+ _settle(descriptor, error, result) {
179
+ // Identity-guarded cleanup: if cancel() evicted this descriptor and a
180
+ // replacement was enqueued under the same key, the maps now point at
181
+ // the new descriptor's controller/promise — unconditional deletes would
182
+ // wipe the replacement's bookkeeping (invisible to hasActiveForReview,
183
+ // immune to cancel, vulnerable to duplicate enqueue).
184
+ if (this.controllers.get(descriptor.key) === descriptor.controller) {
185
+ this.controllers.delete(descriptor.key);
186
+ }
187
+ if (this.inFlight.get(descriptor.key) === descriptor.promise) {
188
+ this.inFlight.delete(descriptor.key);
189
+ }
190
+ this.active--;
191
+ this._onComplete(descriptor.reviewId, descriptor.jobType, error);
192
+ if (error === null) {
193
+ descriptor.resolve(result);
194
+ } else {
195
+ descriptor.reject(error);
196
+ }
197
+ this._drain();
198
+ }
199
+
200
+ /**
201
+ * Is there an in-flight or queued job for this review whose jobType
202
+ * starts with the given prefix? Useful for surfacing a "generating"
203
+ * indicator on the frontend (`hasActiveForReview(id, 'summaries')`).
204
+ *
205
+ * Job keys are stored as `${reviewId}:${jobType}`; `summaries` jobs use
206
+ * the form `summaries:${digest}`, so a prefix match on
207
+ * `${reviewId}:summaries` catches every digest variant.
208
+ *
209
+ * @param {string|number} reviewId
210
+ * @param {string} jobTypePrefix
211
+ * @returns {boolean}
212
+ */
213
+ hasActiveForReview(reviewId, jobTypePrefix) {
214
+ if (!jobTypePrefix) return false;
215
+ const prefix = `${reviewId}:${jobTypePrefix}`;
216
+ for (const key of this.inFlight.keys()) {
217
+ if (key === prefix || key.startsWith(prefix + ':')) return true;
218
+ }
219
+ return false;
220
+ }
221
+
222
+ /**
223
+ * Like `hasActiveForReview` but returns the in-flight/queued jobType string
224
+ * (without the `${reviewId}:` prefix), or null. Useful when callers track a
225
+ * versioned key like `summaries:${digest}` and need to know the *exact* key
226
+ * to cancel before re-enqueueing under a new digest.
227
+ *
228
+ * @param {string|number} reviewId
229
+ * @param {string} jobTypePrefix
230
+ * @returns {string|null}
231
+ */
232
+ findActiveJobType(reviewId, jobTypePrefix) {
233
+ if (!jobTypePrefix) return null;
234
+ const prefix = `${reviewId}:${jobTypePrefix}`;
235
+ for (const key of this.inFlight.keys()) {
236
+ if (key === prefix || key.startsWith(prefix + ':')) {
237
+ return key.slice(String(reviewId).length + 1);
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** Broadcast job completion; broadcast failures are logged, not thrown. */
244
+ _onComplete(reviewId, jobType, error) {
245
+ try {
246
+ // Include whether more jobs of the same type-prefix remain queued or
247
+ // in-flight so listeners (e.g. the summaries toolbar pulse) don't
248
+ // clear their "generating" state when a sibling job is still running.
249
+ // For composite types like `summaries:${digest}`, we strip the suffix
250
+ // so the prefix match catches every digest variant.
251
+ const colonIdx = jobType.indexOf(':');
252
+ const prefix = colonIdx >= 0 ? jobType.slice(0, colonIdx) : jobType;
253
+ const hasActiveForType = this.hasActiveForReview(reviewId, prefix);
254
+ this._deps.broadcast(reviewId, {
255
+ type: 'review:background_job_finished',
256
+ jobType,
257
+ ok: error === null,
258
+ hasActiveForType,
259
+ cancelled: isAbortError(error),
260
+ });
261
+ } catch (broadcastError) {
262
+ logger.warn(
263
+ `BackgroundQueue broadcast failed for ${reviewId}:${jobType}: ${broadcastError.message}`
264
+ );
265
+ }
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Recognize errors that originated from `AbortController.abort()`.
271
+ * Node sets `name === 'AbortError'` on the DOMException for AbortSignal,
272
+ * but providers that wrap the abort in a custom Error may instead set
273
+ * `code === 'ABORT_ERR'` or surface `signal.aborted` themselves. Check
274
+ * the common shapes so the broadcast payload is honest about cancels.
275
+ *
276
+ * @param {unknown} err
277
+ * @returns {boolean}
278
+ */
279
+ function isAbortError(err) {
280
+ if (!err) return false;
281
+ if (typeof err !== 'object') return false;
282
+ if (err.name === 'AbortError') return true;
283
+ if (err.code === 'ABORT_ERR') return true;
284
+ if (err.isCancellation === true) return true;
285
+ return false;
286
+ }
287
+
288
+ const backgroundQueue = new BackgroundQueue();
289
+
290
+ module.exports = { BackgroundQueue, backgroundQueue, BACKGROUND_QUEUE_CONCURRENCY, isAbortError };
@@ -100,7 +100,7 @@ class ClaudeCLI {
100
100
  }
101
101
 
102
102
  // Extract JSON from the text response using robust extraction strategies
103
- const extracted = extractJSON(stdout, level);
103
+ const extracted = extractJSON(stdout, level, levelPrefix);
104
104
  if (extracted.success) {
105
105
  logger.success(`${levelPrefix} Successfully parsed JSON response`);
106
106
  resolve(extracted.data);