@in-the-loop-labs/pair-review 1.4.1 → 1.4.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -460,7 +460,9 @@
460
460
  // ─── Local Review Start ─────────────────────────────────────────────────────
461
461
 
462
462
  /**
463
- * Handle start local review form submission
463
+ * Handle start local review form submission.
464
+ * Navigates to the setup page which shows step-by-step progress,
465
+ * matching the flow used when reviews are started from the MCP/CLI.
464
466
  * @param {Event} event - Form submit event
465
467
  */
466
468
  async function handleStartLocal(event) {
@@ -479,29 +481,9 @@
479
481
  return;
480
482
  }
481
483
 
482
- setFormLoading('local', true, 'Starting local review...');
483
-
484
- try {
485
- const response = await fetch('/api/local/start', {
486
- method: 'POST',
487
- headers: { 'Content-Type': 'application/json' },
488
- body: JSON.stringify({ path: pathValue })
489
- });
490
-
491
- const data = await response.json();
492
-
493
- if (!response.ok) {
494
- throw new Error(data.error || 'Failed to start local review');
495
- }
496
-
497
- setFormLoading('local', true, 'Redirecting to review...');
498
- window.location.href = data.reviewUrl;
499
-
500
- } catch (error) {
501
- console.error('Error starting local review:', error);
502
- setFormLoading('local', false);
503
- showError('local', error.message || 'An unexpected error occurred. Please try again.');
504
- }
484
+ // Navigate to the setup page which shows step-by-step progress
485
+ // The /local?path= route serves setup.html which handles the full setup flow
486
+ window.location.href = '/local?path=' + encodeURIComponent(pathValue);
505
487
  }
506
488
 
507
489
  // ─── Browse Directory ──────────────────────────────────────────────────────
@@ -849,7 +831,10 @@
849
831
  }
850
832
 
851
833
  /**
852
- * Handle start review form submission
834
+ * Handle start review form submission.
835
+ * Parses the PR URL, then navigates to the PR route which serves the
836
+ * setup page with step-by-step progress for new PRs, or the review page
837
+ * directly for PRs that already exist in the database.
853
838
  * @param {Event} event - Form submit event
854
839
  */
855
840
  async function handleStartReview(event) {
@@ -881,44 +866,9 @@
881
866
  return;
882
867
  }
883
868
 
884
- // Update loading state
885
- setFormLoading('pr', true, 'Fetching PR data from GitHub...');
886
-
887
- try {
888
- // Call the API to create the worktree
889
- const response = await fetch('/api/worktrees/create', {
890
- method: 'POST',
891
- headers: {
892
- 'Content-Type': 'application/json'
893
- },
894
- body: JSON.stringify({
895
- owner: parsed.owner,
896
- repo: parsed.repo,
897
- prNumber: parsed.prNumber
898
- })
899
- });
900
-
901
- const data = await response.json();
902
-
903
- if (!response.ok) {
904
- throw new Error(data.error || 'Failed to create worktree');
905
- }
906
-
907
- if (!data.success) {
908
- throw new Error(data.error || 'Failed to create worktree');
909
- }
910
-
911
- // Update loading text before redirect
912
- setFormLoading('pr', true, 'Redirecting to review...');
913
-
914
- // Redirect to the review page
915
- window.location.href = data.reviewUrl;
916
-
917
- } catch (error) {
918
- console.error('Error starting review:', error);
919
- setFormLoading('pr', false);
920
- showError('pr', error.message || 'An unexpected error occurred. Please try again.');
921
- }
869
+ // Navigate to the PR route which serves setup.html (with step-by-step progress)
870
+ // for new PRs, or pr.html directly for PRs already in the database
871
+ window.location.href = '/pr/' + encodeURIComponent(parsed.owner) + '/' + encodeURIComponent(parsed.repo) + '/' + encodeURIComponent(parsed.prNumber);
922
872
  }
923
873
 
924
874
  // ─── Config & Command Examples ──────────────────────────────────────────────
@@ -1068,4 +1018,17 @@
1068
1018
  // natively triggers form submission.
1069
1019
  });
1070
1020
 
1021
+ // ─── bfcache Restoration ───────────────────────────────────────────────────
1022
+
1023
+ // When the browser restores this page from bfcache (e.g. user hits the back
1024
+ // button after navigating away), any in-progress loading state on the forms
1025
+ // will still be visible because the DOM snapshot is preserved as-is. Reset
1026
+ // both forms so the user is not stuck with a disabled input and spinner.
1027
+ window.addEventListener('pageshow', function (event) {
1028
+ if (event.persisted) {
1029
+ setFormLoading('pr', false);
1030
+ setFormLoading('local', false);
1031
+ }
1032
+ });
1033
+
1071
1034
  })();
@@ -650,7 +650,7 @@ class LocalManager {
650
650
  data-file="${fileName}"
651
651
  data-line="${lineStart}"
652
652
  data-line-end="${lineEnd}"
653
- ${side ? `data-side="${side}"` : ''}
653
+ data-side="${side || 'RIGHT'}"
654
654
  >${manager.escapeHtml(currentText)}</textarea>
655
655
  <div class="comment-edit-actions">
656
656
  <button class="btn btn-sm btn-primary save-edit-btn">Save</button>
@@ -245,12 +245,14 @@ class CommentManager {
245
245
  const rows = targetWrapper.querySelectorAll('tr[data-line-number]');
246
246
  const codeLines = [];
247
247
 
248
+ // Always filter by side to prevent including both OLD and NEW versions of modified lines.
249
+ // Default to 'RIGHT' because suggestions target the NEW version of code.
250
+ // This is the definitive fix: even if callers fail to propagate side, we never return both versions.
251
+ const effectiveSide = side || 'RIGHT';
252
+
248
253
  for (const row of rows) {
249
254
  const lineNum = parseInt(row.dataset.lineNumber, 10);
250
- // Filter by line number, file name, and side (if provided)
251
- // Side filtering prevents including both deleted and added versions of modified lines
252
- const matchesSide = !side || row.dataset.side === side;
253
- if (lineNum >= startLine && lineNum <= endLine && row.dataset.fileName === fileName && matchesSide) {
255
+ if (lineNum >= startLine && lineNum <= endLine && row.dataset.fileName === fileName && row.dataset.side === effectiveSide) {
254
256
  // Get the code content cell
255
257
  const codeCell = row.querySelector('.d2h-code-line-ctn');
256
258
  if (codeCell) {
@@ -279,6 +281,9 @@ class CommentManager {
279
281
  const startLine = parseInt(textarea.dataset.line, 10);
280
282
  const endLine = parseInt(textarea.dataset.lineEnd, 10) || startLine;
281
283
  const side = textarea.dataset.side;
284
+ if (!side) {
285
+ console.warn('[Suggestion] textarea missing data-side attribute, defaulting to RIGHT');
286
+ }
282
287
 
283
288
  // Get the code from the selected lines (pass side to avoid including both deleted and added lines)
284
289
  const code = this.getCodeFromLines(fileName, startLine, endLine, side);
@@ -587,7 +592,7 @@ class CommentManager {
587
592
  data-file="${comment.file}"
588
593
  data-line="${comment.line_start}"
589
594
  data-line-end="${comment.line_end || comment.line_start}"
590
- ${comment.side ? `data-side="${comment.side}"` : ''}
595
+ data-side="${comment.side || 'RIGHT'}"
591
596
  >${escapeHtml(comment.body)}</textarea>
592
597
  <div class="comment-edit-actions">
593
598
  <button class="btn btn-sm btn-primary save-edit-btn">
@@ -517,6 +517,8 @@ class DiffRenderer {
517
517
 
518
518
  const headerContent = functionContext
519
519
  ? `<span class="hunk-context-icon" aria-label="Function context">f</span><span class="hunk-context-text">${DiffRenderer.escapeHtml(functionContext)}</span>`
520
+ // "..." dividers act as visual separators between non-contiguous code
521
+ // sections when git couldn't identify a surrounding function/class scope.
520
522
  : '<span class="hunk-divider" aria-label="Code section divider">...</span>';
521
523
 
522
524
  headerRow.innerHTML = `<td colspan="2" class="d2h-info">${headerContent}</td>`;
@@ -554,6 +556,62 @@ class DiffRenderer {
554
556
  trimmedLine.includes(trimmedContext + ' ');
555
557
  }
556
558
 
559
+ /**
560
+ * Handle hunk header rows that are no longer at a gap boundary.
561
+ * A hunk header (d2h-info row) should only be visible when:
562
+ * 1. It is the first row in the tbody (file-level context), OR
563
+ * 2. Its immediately preceding sibling is a gap row (context-expand-row)
564
+ *
565
+ * When a header with function context (_f_ marker) becomes stranded after
566
+ * upward expansion, it is relocated to the nearest gap row above within the
567
+ * same hunk section. This preserves the function context indicator for the
568
+ * remaining gap, whether or not the function definition is now visible below.
569
+ *
570
+ * Headers without function context (... dividers) or those with no reachable
571
+ * gap above are removed.
572
+ * @param {HTMLElement} tbody - The table body containing the diff
573
+ */
574
+ static removeStrandedHunkHeaders(tbody) {
575
+ if (!tbody) return;
576
+
577
+ const infoRows = tbody.querySelectorAll('tr.d2h-info');
578
+ for (const row of infoRows) {
579
+ const prev = row.previousElementSibling;
580
+ // Keep if this is the first row in the tbody (provides file-level context)
581
+ if (!prev) continue;
582
+ // Keep if preceded by a gap row (the header marks the boundary)
583
+ if (prev.classList.contains('context-expand-row')) continue;
584
+
585
+ // Header is stranded between visible code lines.
586
+ // If it has function context, try to relocate it to the nearest gap above
587
+ // so the gap retains its function context indicator.
588
+ if (row.dataset.functionContext) {
589
+ let sibling = row.previousElementSibling;
590
+ let nearestGap = null;
591
+ while (sibling) {
592
+ if (sibling.classList.contains('context-expand-row')) {
593
+ nearestGap = sibling;
594
+ break;
595
+ }
596
+ // Stop at another hunk header - don't cross hunk boundaries.
597
+ // (This also means only the nearest stranded header in DOM order
598
+ // gets relocated to a given gap; subsequent ones are removed.)
599
+ if (sibling.classList.contains('d2h-info')) break;
600
+ sibling = sibling.previousElementSibling;
601
+ }
602
+
603
+ if (nearestGap) {
604
+ // Relocate: move the header to right after the gap row
605
+ nearestGap.insertAdjacentElement('afterend', row);
606
+ continue;
607
+ }
608
+ }
609
+
610
+ // No gap found above or no function context - remove the stranded header
611
+ row.remove();
612
+ }
613
+ }
614
+
557
615
  /**
558
616
  * Update function context visibility for all hunk headers in a file's tbody
559
617
  * Called once after any expansion in a file - checks all headers efficiently
package/public/js/pr.js CHANGED
@@ -1455,9 +1455,10 @@ class PRManager {
1455
1455
  gapRow.parentNode.insertBefore(fragment, gapRow);
1456
1456
  gapRow.remove();
1457
1457
 
1458
- // Check all function context markers in this file and remove any whose
1459
- // function definitions are now visible
1458
+ // Remove hunk headers that are no longer at a gap boundary,
1459
+ // then check remaining headers for visible function definitions
1460
1460
  if (window.DiffRenderer) {
1461
+ window.DiffRenderer.removeStrandedHunkHeaders(tbody);
1461
1462
  window.DiffRenderer.updateFunctionContextVisibility(tbody);
1462
1463
  }
1463
1464
 
@@ -1478,6 +1479,7 @@ class PRManager {
1478
1479
  const hasExplicitEndLineNew = !isNaN(parseInt(controls.dataset.endLineNew));
1479
1480
 
1480
1481
  const fileName = controls.dataset.fileName;
1482
+ const position = controls.dataset.position || 'between';
1481
1483
  const tbody = gapRow.closest('tbody');
1482
1484
 
1483
1485
  if (!tbody) return;
@@ -1488,6 +1490,13 @@ class PRManager {
1488
1490
 
1489
1491
  const fragment = document.createDocumentFragment();
1490
1492
 
1493
+ // Compute positions for each remnant based on file boundary proximity.
1494
+ // The upper remnant keeps 'above' only if the original gap was at the file start;
1495
+ // the lower remnant keeps 'below' only if the original gap was at the file end.
1496
+ // Inner remnants become 'between' since they're sandwiched between visible content.
1497
+ const gapAbovePosition = position === 'above' ? 'above' : 'between';
1498
+ const gapBelowPosition = position === 'below' ? 'below' : 'between';
1499
+
1491
1500
  // Create gap above if needed
1492
1501
  const gapAboveSize = expandStart - gapStart;
1493
1502
  if (gapAboveSize > 0) {
@@ -1496,7 +1505,7 @@ class PRManager {
1496
1505
  gapStart,
1497
1506
  expandStart - 1,
1498
1507
  gapAboveSize,
1499
- 'above',
1508
+ gapAbovePosition,
1500
1509
  (controls, dir, cnt) => this.expandGapContext(controls, dir, cnt),
1501
1510
  gapStartNew // Preserve the NEW line number offset
1502
1511
  );
@@ -1537,7 +1546,7 @@ class PRManager {
1537
1546
  expandEnd + 1,
1538
1547
  gapEnd,
1539
1548
  gapBelowSize,
1540
- 'below',
1549
+ gapBelowPosition,
1541
1550
  (controls, dir, cnt) => this.expandGapContext(controls, dir, cnt),
1542
1551
  belowGapStartNew // Updated NEW line number for gap below
1543
1552
  );
@@ -1553,9 +1562,10 @@ class PRManager {
1553
1562
  gapRow.parentNode.insertBefore(fragment, gapRow);
1554
1563
  gapRow.remove();
1555
1564
 
1556
- // Check all function context markers in this file and remove any whose
1557
- // function definitions are now visible
1565
+ // Remove hunk headers that are no longer at a gap boundary,
1566
+ // then check remaining headers for visible function definitions
1558
1567
  if (window.DiffRenderer) {
1568
+ window.DiffRenderer.removeStrandedHunkHeaders(tbody);
1559
1569
  window.DiffRenderer.updateFunctionContextVisibility(tbody);
1560
1570
  }
1561
1571
 
@@ -1718,10 +1728,6 @@ class PRManager {
1718
1728
  this.commentManager.updateSuggestionButtonState(textarea, button);
1719
1729
  }
1720
1730
 
1721
- getCodeFromLines(fileName, startLine, endLine) {
1722
- return this.commentManager.getCodeFromLines(fileName, startLine, endLine);
1723
- }
1724
-
1725
1731
  insertSuggestionBlock(textarea, button) {
1726
1732
  this.commentManager.insertSuggestionBlock(textarea, button);
1727
1733
  }
@@ -1783,7 +1789,7 @@ class PRManager {
1783
1789
  data-file="${fileName}"
1784
1790
  data-line="${lineStart}"
1785
1791
  data-line-end="${lineEnd}"
1786
- ${side ? `data-side="${side}"` : ''}
1792
+ data-side="${side || 'RIGHT'}"
1787
1793
  >${this.escapeHtml(currentText)}</textarea>
1788
1794
  <div class="comment-edit-actions">
1789
1795
  <button class="btn btn-sm btn-primary save-edit-btn">Save</button>
@@ -7,7 +7,7 @@
7
7
 
8
8
  const path = require('path');
9
9
  const { spawn } = require('child_process');
10
- const { AIProvider, registerProvider } = require('./provider');
10
+ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
11
11
  const logger = require('../utils/logger');
12
12
  const { extractJSON } = require('../utils/json-extractor');
13
13
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
@@ -178,7 +178,7 @@ class ClaudeProvider extends AIProvider {
178
178
 
179
179
  if (this.useShell) {
180
180
  const allArgs = [...baseArgs, ...extraArgs];
181
- this.command = `${claudeCmd} ${this._quoteShellArgs(allArgs).join(' ')}`;
181
+ this.command = `${claudeCmd} ${quoteShellArgs(allArgs).join(' ')}`;
182
182
  this.args = [];
183
183
  } else {
184
184
  this.command = claudeCmd;
@@ -186,28 +186,6 @@ class ClaudeProvider extends AIProvider {
186
186
  }
187
187
  }
188
188
 
189
- /**
190
- * Quote shell-sensitive arguments for safe shell execution.
191
- * Any arg containing characters that could be interpreted by the shell
192
- * (brackets, parentheses, commas, etc.) is wrapped in single quotes
193
- * with internal single quotes escaped using the POSIX pattern.
194
- *
195
- * @param {string[]} args - Array of CLI arguments
196
- * @returns {string[]} Args with shell-sensitive values quoted
197
- * @private
198
- */
199
- _quoteShellArgs(args) {
200
- return args.map((arg, i) => {
201
- const prevArg = args[i - 1];
202
- if (prevArg === '--allowedTools' || prevArg === '--model') {
203
- if (/[][*?(){}$!&|;<>,\s']/.test(arg)) {
204
- return `'${arg.replace(/'/g, "'\\''")}'`;
205
- }
206
- }
207
- return arg;
208
- });
209
- }
210
-
211
189
  /**
212
190
  * Resolve model configuration by looking up built-in and config override definitions.
213
191
  * Consolidates the CLAUDE_MODELS.find() and configOverrides.models.find() lookups
@@ -486,7 +464,7 @@ class ClaudeProvider extends AIProvider {
486
464
  const args = ['-p', ...cliModelArgs, ...extraArgs];
487
465
 
488
466
  if (useShell) {
489
- const quotedArgs = this._quoteShellArgs(args);
467
+ const quotedArgs = quoteShellArgs(args);
490
468
  return {
491
469
  command: `${claudeCmd} ${quotedArgs.join(' ')}`,
492
470
  args: [],
@@ -8,7 +8,7 @@
8
8
 
9
9
  const path = require('path');
10
10
  const { spawn } = require('child_process');
11
- const { AIProvider, registerProvider } = require('./provider');
11
+ 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');
@@ -22,8 +22,8 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
22
22
  *
23
23
  * Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
24
24
  * - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
25
- * - gpt-5.1-codex-max: Optimized for long-horizon agentic coding tasks
26
- * - gpt-5.2-codex: Most advanced agentic coding model for real-world engineering
25
+ * - gpt-5.2-codex: Advanced coding model for everyday reviews, good reasoning/cost balance
26
+ * - gpt-5.3-codex: Most capable agentic coding model with frontier performance and reasoning
27
27
  */
28
28
  const CODEX_MODELS = [
29
29
  {
@@ -36,21 +36,21 @@ const CODEX_MODELS = [
36
36
  badgeClass: 'badge-speed'
37
37
  },
38
38
  {
39
- id: 'gpt-5.1-codex-max',
40
- name: 'GPT-5.1 Max',
39
+ id: 'gpt-5.2-codex',
40
+ name: 'GPT-5.2 Codex',
41
41
  tier: 'balanced',
42
42
  tagline: 'Best Balance',
43
- description: 'Strong everyday reviewer—quality + speed for PR-sized changes and practical suggestions.',
43
+ description: 'Strong everyday reviewer—good reasoning and code understanding for PR-sized changes without top-tier cost.',
44
44
  badge: 'Recommended',
45
45
  badgeClass: 'badge-recommended',
46
46
  default: true
47
47
  },
48
48
  {
49
- id: 'gpt-5.2-codex',
50
- name: 'GPT-5.2',
49
+ id: 'gpt-5.3-codex',
50
+ name: 'GPT-5.3 Codex',
51
51
  tier: 'thorough',
52
52
  tagline: 'Deep Review',
53
- description: 'Most capable for complex diffsfinds subtle issues, reasons across files, and proposes step-by-step fixes.',
53
+ description: 'Most capable agentic coding modelcombines frontier coding performance with stronger reasoning for deep cross-file analysis.',
54
54
  badge: 'Most Thorough',
55
55
  badgeClass: 'badge-power'
56
56
  }
@@ -65,7 +65,7 @@ class CodexProvider extends AIProvider {
65
65
  * @param {Object} configOverrides.env - Additional environment variables
66
66
  * @param {Object[]} configOverrides.models - Custom model definitions
67
67
  */
68
- constructor(model = 'gpt-5.1-codex-max', configOverrides = {}) {
68
+ constructor(model = 'gpt-5.2-codex', configOverrides = {}) {
69
69
  super(model);
70
70
 
71
71
  // Command precedence: ENV > config > default
@@ -116,7 +116,7 @@ class CodexProvider extends AIProvider {
116
116
 
117
117
  if (this.useShell) {
118
118
  // In shell mode, build full command string with args
119
- this.command = `${codexCmd} ${[...baseArgs, ...providerArgs, ...modelArgs].join(' ')}`;
119
+ this.command = `${codexCmd} ${quoteShellArgs([...baseArgs, ...providerArgs, ...modelArgs]).join(' ')}`;
120
120
  this.args = [];
121
121
  } else {
122
122
  this.command = codexCmd;
@@ -577,7 +577,7 @@ class CodexProvider extends AIProvider {
577
577
 
578
578
  if (useShell) {
579
579
  return {
580
- command: `${codexCmd} ${args.join(' ')}`,
580
+ command: `${codexCmd} ${quoteShellArgs(args).join(' ')}`,
581
581
  args: [],
582
582
  useShell: true,
583
583
  promptViaStdin: true
@@ -676,7 +676,7 @@ class CodexProvider extends AIProvider {
676
676
  }
677
677
 
678
678
  static getDefaultModel() {
679
- return 'gpt-5.1-codex-max';
679
+ return 'gpt-5.2-codex';
680
680
  }
681
681
 
682
682
  static getInstallInstructions() {
@@ -8,7 +8,7 @@
8
8
 
9
9
  const path = require('path');
10
10
  const { spawn } = require('child_process');
11
- const { AIProvider, registerProvider } = require('./provider');
11
+ 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');
@@ -21,42 +21,63 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
21
21
  *
22
22
  * GitHub Copilot CLI supports multiple AI models including OpenAI,
23
23
  * Anthropic, and Google models via the --model flag.
24
+ * Available models (as of Feb 2026): claude-haiku-4.5, claude-sonnet-4.5,
25
+ * gemini-3-pro-preview, gpt-5.2-codex, claude-opus-4.5,
26
+ * claude-opus-4.6. Default is claude-sonnet-4.5.
24
27
  */
25
28
  const COPILOT_MODELS = [
26
29
  {
27
- id: 'gpt-5.1-codex-mini',
28
- name: 'GPT-5.1 Mini',
30
+ id: 'claude-haiku-4.5',
31
+ name: 'Claude Haiku 4.5',
29
32
  tier: 'fast',
30
33
  tagline: 'Quick Scan',
31
- description: 'Rapid feedback for obvious issues and style checks',
34
+ description: 'Rapid feedback for obvious issues, style checks, and simple logic errors',
32
35
  badge: 'Speedy',
33
36
  badgeClass: 'badge-speed'
34
37
  },
35
38
  {
36
- id: 'gemini-3-pro-preview',
37
- name: 'Gemini 3 Pro',
39
+ id: 'claude-sonnet-4.5',
40
+ name: 'Claude Sonnet 4.5',
38
41
  tier: 'balanced',
39
42
  tagline: 'Reliable Review',
40
- description: 'Solid everyday reviews with good coverage',
43
+ description: 'Copilot default—strong code understanding with excellent quality-to-cost ratio',
41
44
  badge: 'Recommended',
42
45
  badgeClass: 'badge-recommended',
43
46
  default: true
44
47
  },
45
48
  {
46
- id: 'gpt-5.1-codex-max',
47
- name: 'GPT-5.1 Max',
48
- tier: 'thorough',
49
- tagline: 'Deep Analysis',
50
- description: 'Comprehensive reviews for complex changes',
51
- badge: 'Thorough',
52
- badgeClass: 'badge-power'
49
+ id: 'gemini-3-pro-preview',
50
+ name: 'Gemini 3 Pro',
51
+ tier: 'balanced',
52
+ tagline: 'Strong Alternative',
53
+ description: "Google's most capable model—strong reasoning for cross-file analysis",
54
+ badge: 'Balanced',
55
+ badgeClass: 'badge-balanced'
56
+ },
57
+ {
58
+ id: 'gpt-5.2-codex',
59
+ name: 'GPT-5.2 Codex',
60
+ tier: 'balanced',
61
+ tagline: 'Alternative View',
62
+ description: 'OpenAI code-specialized model—different perspective for cross-file analysis',
63
+ badge: 'Balanced',
64
+ badgeClass: 'badge-balanced'
53
65
  },
54
66
  {
55
67
  id: 'claude-opus-4.5',
56
68
  name: 'Claude Opus 4.5',
57
- tier: 'premium',
58
- tagline: 'Ultimate Review',
59
- description: 'The most capable model for critical code reviews',
69
+ tier: 'thorough',
70
+ tagline: 'Deep Analysis',
71
+ description: 'Highly capable model for critical code reviews—strong reasoning for security and architecture',
72
+ badge: 'Premium',
73
+ badgeClass: 'badge-premium'
74
+ },
75
+ {
76
+ id: 'claude-opus-4.6',
77
+ name: 'Claude Opus 4.6',
78
+ tier: 'thorough',
79
+ tagline: 'Most Capable',
80
+ description: 'Most capable model for critical code reviews—deep reasoning for security and architecture',
60
81
  badge: 'Premium',
61
82
  badgeClass: 'badge-premium'
62
83
  }
@@ -71,7 +92,7 @@ class CopilotProvider extends AIProvider {
71
92
  * @param {Object} configOverrides.env - Additional environment variables
72
93
  * @param {Object[]} configOverrides.models - Custom model definitions
73
94
  */
74
- constructor(model = 'gemini-3-pro-preview', configOverrides = {}) {
95
+ constructor(model = 'claude-sonnet-4.5', configOverrides = {}) {
75
96
  super(model);
76
97
 
77
98
  // Command precedence: ENV > config > default
@@ -191,7 +212,7 @@ class CopilotProvider extends AIProvider {
191
212
  // Escape the prompt for shell
192
213
  const escapedPrompt = prompt.replace(/'/g, "'\\''");
193
214
  // Build: copilot --model X --deny-tool ... -s -p 'prompt'
194
- fullCommand = `${this.command} ${this.baseArgs.join(' ')} -p '${escapedPrompt}'`;
215
+ fullCommand = `${this.command} ${quoteShellArgs(this.baseArgs).join(' ')} -p '${escapedPrompt}'`;
195
216
  fullArgs = [];
196
217
  } else {
197
218
  // Build args array: --model X --deny-tool ... -s -p <prompt>
@@ -359,7 +380,7 @@ class CopilotProvider extends AIProvider {
359
380
  // Use stdin for prompt - safer than command args for arbitrary content
360
381
  if (useShell) {
361
382
  return {
362
- command: `${copilotCmd} ${args.join(' ')}`,
383
+ command: `${copilotCmd} ${quoteShellArgs(args).join(' ')}`,
363
384
  args: [],
364
385
  useShell: true,
365
386
  promptViaStdin: true
@@ -441,7 +462,7 @@ class CopilotProvider extends AIProvider {
441
462
  }
442
463
 
443
464
  static getDefaultModel() {
444
- return 'gemini-3-pro-preview';
465
+ return 'claude-sonnet-4.5';
445
466
  }
446
467
 
447
468
  static getInstallInstructions() {
@@ -16,7 +16,7 @@
16
16
 
17
17
  const path = require('path');
18
18
  const { spawn } = require('child_process');
19
- const { AIProvider, registerProvider } = require('./provider');
19
+ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
20
20
  const logger = require('../utils/logger');
21
21
  const { extractJSON } = require('../utils/json-extractor');
22
22
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
@@ -30,9 +30,9 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
30
30
  *
31
31
  * Tier structure:
32
32
  * - free (auto): Cursor's default auto-routing model
33
- * - fast (gpt-5.2-codex-fast): Quick code-specialized analysis
34
- * - balanced (sonnet-4.5-thinking, gemini-3-pro): Recommended for most reviews
35
- * - thorough (gpt-5.2-codex-high, opus-4.5-thinking): Deep analysis for complex code
33
+ * - fast (composer-1, gpt-5.3-codex-fast, gemini-3-flash): Quick analysis
34
+ * - balanced (composer-1.5, sonnet-4.5-thinking, gemini-3-pro): Recommended for most reviews
35
+ * - thorough (gpt-5.3-codex-high, opus-4.5-thinking, opus-4.6-thinking): Deep analysis for complex code
36
36
  */
37
37
  const CURSOR_AGENT_MODELS = [
38
38
  {
@@ -45,20 +45,47 @@ const CURSOR_AGENT_MODELS = [
45
45
  badgeClass: 'badge-speed'
46
46
  },
47
47
  {
48
- id: 'gpt-5.2-codex-fast',
49
- name: 'GPT-5.2 Codex Fast',
48
+ id: 'composer-1.5',
49
+ name: 'Composer 1.5',
50
+ tier: 'balanced',
51
+ tagline: 'Latest Composer',
52
+ description: 'Cursor Composer model—positioned between Sonnet and Opus for multi-file edits',
53
+ badge: 'Balanced',
54
+ badgeClass: 'badge-balanced'
55
+ },
56
+ {
57
+ id: 'composer-1',
58
+ name: 'Composer 1',
59
+ tier: 'fast',
60
+ tagline: 'Original Composer',
61
+ description: 'Cursor Composer model—good for quick multi-file editing workflows',
62
+ badge: 'Fast',
63
+ badgeClass: 'badge-speed'
64
+ },
65
+ {
66
+ id: 'gpt-5.3-codex-fast',
67
+ name: 'GPT-5.3 Codex Fast',
50
68
  tier: 'fast',
51
69
  tagline: 'Lightning Fast',
52
- description: 'Quick code-specialized analysis for simple changes',
70
+ description: 'Latest code-specialized model optimized for speed—quick scans for obvious issues',
53
71
  badge: 'Fastest',
54
72
  badgeClass: 'badge-speed'
55
73
  },
74
+ {
75
+ id: 'gemini-3-flash',
76
+ name: 'Gemini 3 Flash',
77
+ tier: 'fast',
78
+ tagline: 'Fast & Capable',
79
+ description: 'High SWE-bench scores at a fraction of the cost—great for quick reviews',
80
+ badge: 'Fast',
81
+ badgeClass: 'badge-speed'
82
+ },
56
83
  {
57
84
  id: 'sonnet-4.5-thinking',
58
85
  name: 'Claude 4.5 Sonnet (Thinking)',
59
86
  tier: 'balanced',
60
87
  tagline: 'Best Balance',
61
- description: 'Extended thinking for thorough analysis',
88
+ description: 'Extended thinking for thorough analysis with excellent quality-to-cost ratio',
62
89
  badge: 'Recommended',
63
90
  badgeClass: 'badge-recommended',
64
91
  default: true
@@ -68,16 +95,16 @@ const CURSOR_AGENT_MODELS = [
68
95
  name: 'Gemini 3 Pro',
69
96
  tier: 'balanced',
70
97
  tagline: 'Strong Alternative',
71
- description: "Google's flagship model for code review",
98
+ description: "Google's flagship model for code review—strong agentic and vibe coding capabilities",
72
99
  badge: 'Balanced',
73
100
  badgeClass: 'badge-balanced'
74
101
  },
75
102
  {
76
- id: 'gpt-5.2-codex-high',
77
- name: 'GPT-5.2 Codex High',
103
+ id: 'gpt-5.3-codex-high',
104
+ name: 'GPT-5.3 Codex High',
78
105
  tier: 'thorough',
79
106
  tagline: 'Deep Code Analysis',
80
- description: "OpenAI's best for complex code review",
107
+ description: "OpenAI's latest and most capable for complex code review with deep reasoning",
81
108
  badge: 'Thorough',
82
109
  badgeClass: 'badge-power'
83
110
  },
@@ -85,8 +112,17 @@ const CURSOR_AGENT_MODELS = [
85
112
  id: 'opus-4.5-thinking',
86
113
  name: 'Claude 4.5 Opus (Thinking)',
87
114
  tier: 'thorough',
115
+ tagline: 'Deep Analysis',
116
+ description: 'Deep analysis with extended thinking for complex code reviews',
117
+ badge: 'Thorough',
118
+ badgeClass: 'badge-power'
119
+ },
120
+ {
121
+ id: 'opus-4.6-thinking',
122
+ name: 'Claude 4.6 Opus (Thinking)',
123
+ tier: 'thorough',
88
124
  tagline: 'Most Capable',
89
- description: 'Deep analysis with extended thinking for complex code',
125
+ description: 'Deep analysis with extended thinking—Cursor default for maximum review quality',
90
126
  badge: 'Most Thorough',
91
127
  badgeClass: 'badge-power'
92
128
  }
@@ -159,7 +195,7 @@ class CursorAgentProvider extends AIProvider {
159
195
 
160
196
  if (this.useShell) {
161
197
  // In shell mode, build full command string with args
162
- this.command = `${agentCmd} ${[...baseArgs, ...providerArgs, ...modelArgs].join(' ')}`;
198
+ this.command = `${agentCmd} ${quoteShellArgs([...baseArgs, ...providerArgs, ...modelArgs]).join(' ')}`;
163
199
  this.args = [];
164
200
  } else {
165
201
  this.command = agentCmd;
@@ -662,7 +698,7 @@ class CursorAgentProvider extends AIProvider {
662
698
  // For extraction, we pass the prompt via stdin
663
699
  if (useShell) {
664
700
  return {
665
- command: `${agentCmd} ${args.join(' ')}`,
701
+ command: `${agentCmd} ${quoteShellArgs(args).join(' ')}`,
666
702
  args: [],
667
703
  useShell: true,
668
704
  promptViaStdin: true
@@ -7,7 +7,7 @@
7
7
 
8
8
  const path = require('path');
9
9
  const { spawn } = require('child_process');
10
- const { AIProvider, registerProvider } = require('./provider');
10
+ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
11
11
  const logger = require('../utils/logger');
12
12
  const { extractJSON } = require('../utils/json-extractor');
13
13
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
@@ -25,7 +25,7 @@ const GEMINI_MODELS = [
25
25
  name: '3.0 Flash',
26
26
  tier: 'fast',
27
27
  tagline: 'Rapid Sanity Check',
28
- description: 'Best for catching syntax, typos, and simple logic errors',
28
+ description: 'Fast and capable at a fraction of the cost of larger models',
29
29
  badge: 'Quick Look',
30
30
  badgeClass: 'badge-speed'
31
31
  },
@@ -34,7 +34,7 @@ const GEMINI_MODELS = [
34
34
  name: '2.5 Pro',
35
35
  tier: 'balanced',
36
36
  tagline: 'Standard PR Review',
37
- description: 'Reliable feedback on code style, features, and refactoring',
37
+ description: 'Strong reasoning with large context window—reliable for everyday code reviews',
38
38
  badge: 'Daily Driver',
39
39
  badgeClass: 'badge-recommended',
40
40
  default: true
@@ -44,7 +44,7 @@ const GEMINI_MODELS = [
44
44
  name: '3.0 Pro',
45
45
  tier: 'thorough',
46
46
  tagline: 'Architectural Audit',
47
- description: 'Deep analysis for race conditions, security, and edge cases',
47
+ description: 'Most intelligent Gemini model—advanced reasoning for deep architectural analysis',
48
48
  badge: 'Deep Dive',
49
49
  badgeClass: 'badge-power'
50
50
  }
@@ -156,16 +156,9 @@ class GeminiProvider extends AIProvider {
156
156
 
157
157
  if (this.useShell) {
158
158
  // In shell mode, build full command string with args
159
- // Quote the allowed-tools value to prevent shell interpretation of special characters
159
+ // Quote all args to prevent shell interpretation of special characters
160
160
  // (commas, parentheses in patterns like "run_shell_command(git diff)")
161
- const quotedBaseArgs = baseArgs.map((arg, i) => {
162
- // The allowed-tools value follows the --allowed-tools flag
163
- if (baseArgs[i - 1] === '--allowed-tools') {
164
- return `'${arg}'`;
165
- }
166
- return arg;
167
- });
168
- this.command = `${geminiCmd} ${[...quotedBaseArgs, ...providerArgs, ...modelArgs].join(' ')}`;
161
+ this.command = `${geminiCmd} ${quoteShellArgs([...baseArgs, ...providerArgs, ...modelArgs]).join(' ')}`;
169
162
  this.args = [];
170
163
  } else {
171
164
  this.command = geminiCmd;
@@ -616,7 +609,7 @@ class GeminiProvider extends AIProvider {
616
609
  // For extraction, we pass the prompt via stdin
617
610
  if (useShell) {
618
611
  return {
619
- command: `${geminiCmd} ${args.join(' ')}`,
612
+ command: `${geminiCmd} ${quoteShellArgs(args).join(' ')}`,
620
613
  args: [],
621
614
  useShell: true,
622
615
  promptViaStdin: true
@@ -13,7 +13,7 @@
13
13
 
14
14
  const path = require('path');
15
15
  const { spawn } = require('child_process');
16
- const { AIProvider, registerProvider } = require('./provider');
16
+ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
17
17
  const logger = require('../utils/logger');
18
18
  const { extractJSON } = require('../utils/json-extractor');
19
19
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
@@ -114,7 +114,7 @@ class OpenCodeProvider extends AIProvider {
114
114
  let fullArgs;
115
115
 
116
116
  if (this.useShell) {
117
- fullCommand = `${this.opencodeCmd} ${this.baseArgs.join(' ')}`;
117
+ fullCommand = `${this.opencodeCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
118
118
  fullArgs = [];
119
119
  } else {
120
120
  fullCommand = this.opencodeCmd;
@@ -554,7 +554,7 @@ class OpenCodeProvider extends AIProvider {
554
554
  // OpenCode reads from stdin when no positional message arguments are provided
555
555
  if (useShell) {
556
556
  return {
557
- command: `${opencodeCmd} ${args.join(' ')}`,
557
+ command: `${opencodeCmd} ${quoteShellArgs(args).join(' ')}`,
558
558
  args: [],
559
559
  useShell: true,
560
560
  promptViaStdin: true
@@ -18,7 +18,7 @@
18
18
 
19
19
  const path = require('path');
20
20
  const { spawn } = require('child_process');
21
- const { AIProvider, registerProvider } = require('./provider');
21
+ const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
22
22
  const logger = require('../utils/logger');
23
23
  const { extractJSON } = require('../utils/json-extractor');
24
24
  const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
@@ -253,7 +253,7 @@ class PiProvider extends AIProvider {
253
253
  let fullArgs;
254
254
 
255
255
  if (this.useShell) {
256
- fullCommand = `${this.piCmd} ${this.baseArgs.join(' ')}`;
256
+ fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
257
257
  fullArgs = [];
258
258
  } else {
259
259
  fullCommand = this.piCmd;
@@ -745,7 +745,7 @@ class PiProvider extends AIProvider {
745
745
  // Pi reads from stdin when using -p with no positional message arguments
746
746
  if (useShell) {
747
747
  return {
748
- command: `${piCmd} ${args.join(' ')}`,
748
+ command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
749
749
  args: [],
750
750
  useShell: true,
751
751
  promptViaStdin: true,
@@ -14,6 +14,24 @@ const { extractJSON } = require('../utils/json-extractor');
14
14
  // Directory containing bin scripts (git-diff-lines, etc.)
15
15
  const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
16
16
 
17
+ /**
18
+ * Quote shell-sensitive arguments for safe shell execution.
19
+ * Any arg containing characters that could be interpreted by the shell
20
+ * (brackets, parentheses, commas, etc.) is wrapped in single quotes
21
+ * with internal single quotes escaped using the POSIX pattern.
22
+ *
23
+ * @param {string[]} args - Array of CLI arguments
24
+ * @returns {string[]} Args with shell-sensitive values quoted
25
+ */
26
+ function quoteShellArgs(args) {
27
+ return args.map(arg => {
28
+ if (/[[\]*?(){}$!&|;<>,\s'"\\`#~]/.test(arg)) {
29
+ return `'${arg.replace(/'/g, "'\\''")}'`;
30
+ }
31
+ return arg;
32
+ });
33
+ }
34
+
17
35
  /**
18
36
  * Model tier definitions - provider-agnostic tiers that map to specific models
19
37
  */
@@ -639,6 +657,7 @@ async function testProviderAvailability(providerId, timeout = 10000) {
639
657
  module.exports = {
640
658
  AIProvider,
641
659
  MODEL_TIERS,
660
+ quoteShellArgs,
642
661
  registerProvider,
643
662
  getProviderClass,
644
663
  getRegisteredProviderIds,