@in-the-loop-labs/pair-review 3.9.1 → 4.1.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 (53) hide show
  1. package/README.md +92 -17
  2. package/package.json +6 -9
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/components/AdvancedConfigTab.js +1 -1
  8. package/public/js/components/AnalysisConfigModal.js +2 -2
  9. package/public/js/index.js +87 -10
  10. package/public/js/local.js +20 -10
  11. package/public/js/pr.js +67 -39
  12. package/public/js/repo-links.js +11 -3
  13. package/public/js/utils/analyze-params.js +69 -0
  14. package/public/js/utils/provider-model.js +67 -2
  15. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  16. package/public/js/vendor/pierre-diffs.js +1880 -0
  17. package/public/local.html +1 -0
  18. package/public/pr.html +1 -0
  19. package/public/setup.html +35 -16
  20. package/src/ai/analyzer.js +4 -4
  21. package/src/ai/antigravity-provider.js +594 -0
  22. package/src/ai/index.js +1 -1
  23. package/src/ai/opencode-provider.js +1 -1
  24. package/src/ai/provider.js +5 -5
  25. package/src/ai/stream-parser.js +1 -52
  26. package/src/chat/acp-bridge.js +1 -1
  27. package/src/chat/chat-providers.js +1 -9
  28. package/src/config.js +153 -31
  29. package/src/database.js +128 -4
  30. package/src/external/github-adapter.js +18 -3
  31. package/src/github/client.js +37 -0
  32. package/src/github/parser.js +41 -7
  33. package/src/interactive-analysis-config.js +2 -2
  34. package/src/links/repo-links.js +66 -28
  35. package/src/local-review.js +134 -5
  36. package/src/local-scope.js +38 -0
  37. package/src/main.js +203 -37
  38. package/src/routes/config.js +49 -14
  39. package/src/routes/external-comments.js +13 -1
  40. package/src/routes/github-collections.js +175 -13
  41. package/src/routes/local.js +11 -20
  42. package/src/routes/pr.js +63 -36
  43. package/src/routes/setup.js +63 -8
  44. package/src/routes/shared.js +85 -0
  45. package/src/routes/stack-analysis.js +39 -3
  46. package/src/server.js +74 -3
  47. package/src/setup/local-setup.js +23 -7
  48. package/src/setup/pr-setup.js +237 -39
  49. package/src/setup/stack-setup.js +7 -2
  50. package/src/single-port.js +73 -18
  51. package/src/utils/host-resolution.js +157 -0
  52. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
  53. package/src/ai/gemini-provider.js +0 -752
@@ -445,8 +445,14 @@
445
445
  ? ''
446
446
  : '<td class="col-author">' + authorDisplay + '</td>';
447
447
 
448
+ // `data-host` carries the alt-host api_host string for PRs that live on an
449
+ // alternate host; github.com rows have no host and render an empty
450
+ // attribute. The click handler threads it into the setup call so a dual
451
+ // repo's alt-host PR opens against the right system without re-probing.
452
+ var hostAttr = pr.host ? ' data-host="' + escapeHtml(pr.host) + '"' : '';
453
+
448
454
  return '' +
449
- '<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '" data-owner="' + escapeHtml(pr.owner) + '" data-repo="' + escapeHtml(pr.repo) + '" data-number="' + pr.number + '">' +
455
+ '<tr class="collection-pr-row" data-pr-url="' + escapeHtml(prUrl) + '" data-owner="' + escapeHtml(pr.owner) + '" data-repo="' + escapeHtml(pr.repo) + '" data-number="' + pr.number + '"' + hostAttr + '>' +
450
456
  '<td class="col-repo">' + escapeHtml(repoFull) + '</td>' +
451
457
  '<td class="col-pr"><span class="collection-pr-number">#' + pr.number + '</span></td>' +
452
458
  '<td class="col-title" title="' + escapeHtml(pr.title || '') + '">' + escapeHtml(pr.title || '') + '</td>' +
@@ -1293,7 +1299,14 @@
1293
1299
  return {
1294
1300
  owner: data.owner,
1295
1301
  repo: data.repo,
1296
- prNumber: data.prNumber
1302
+ prNumber: data.prNumber,
1303
+ // `host` tells setup which system the PR lives on: an api_host URL
1304
+ // string = alt host, null = github.com, undefined = unknown.
1305
+ host: data.host,
1306
+ bindingRepository: data.bindingRepository,
1307
+ // Whether the repo is dual (github + alt-host). Only dual repos probe,
1308
+ // so only they need an explicit github pick when the URL is a github URL.
1309
+ isDualHost: data.isDualHost
1297
1310
  };
1298
1311
  }
1299
1312
 
@@ -1343,7 +1356,22 @@
1343
1356
  // Navigate to the PR route which serves setup.html (with step-by-step progress)
1344
1357
  // for new PRs, or pr.html directly for PRs already in the database
1345
1358
  let href = '/pr/' + encodeURIComponent(parsed.owner) + '/' + encodeURIComponent(parsed.repo) + '/' + encodeURIComponent(parsed.prNumber);
1346
- if (analyze) href += '?analyze=true';
1359
+ var params = new URLSearchParams();
1360
+ if (analyze) params.set('analyze', 'true');
1361
+ // Forward the parsed host so setup binds directly instead of probing:
1362
+ // - alt-host URL string → the api_host (setup binds that alt host)
1363
+ // - github URL (host === null) on a DUAL repo → the "github" sentinel, so
1364
+ // setup binds github.com instead of probing alt-first (which would fail
1365
+ // loudly if the alt host is down for a PR we KNOW is on github)
1366
+ // - anything else (plain/exclusive repo, unknown host) → omit; no probe
1367
+ // happens for those and omitting avoids the exclusive-null throw.
1368
+ if (typeof parsed.host === 'string' && parsed.host) {
1369
+ params.set('host', parsed.host);
1370
+ } else if (parsed.host === null && parsed.isDualHost) {
1371
+ params.set('host', 'github');
1372
+ }
1373
+ var qs = params.toString();
1374
+ if (qs) href += '?' + qs;
1347
1375
  window.location.href = href;
1348
1376
  }
1349
1377
 
@@ -1385,6 +1413,11 @@
1385
1413
  window.__pairReview.chatProviders = chatProviders;
1386
1414
  window.__pairReview.defaultProvider = config.default_provider || 'claude';
1387
1415
  window.__pairReview.defaultModel = config.default_model || 'opus';
1416
+ // CLI/env override (PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL) surfaced
1417
+ // by /api/config. The bulk-analysis seed prepends these ahead of repo
1418
+ // settings so a `--provider` override outranks a repo's saved default.
1419
+ window.__pairReview.providerOverride = config.provider_override || null;
1420
+ window.__pairReview.modelOverride = config.model_override || null;
1388
1421
  window.__pairReview.hasGithubToken = Boolean(config.has_github_token);
1389
1422
  window.__pairReview.enableGraphite = config.enable_graphite === true;
1390
1423
  window.__pairReview.chatSpinner = config.chat_spinner || 'dots';
@@ -1891,7 +1924,7 @@
1891
1924
  * Read selected collection rows as PR descriptors.
1892
1925
  * @param {Set} selectedIds - PR URLs (data-pr-url values)
1893
1926
  * @param {string} tbodyId - tbody element ID
1894
- * @returns {Array<{owner: string, repo: string, number: string, prUrl: string}>}
1927
+ * @returns {Array<{owner: string, repo: string, number: string, prUrl: string, host: string|undefined}>}
1895
1928
  */
1896
1929
  function getSelectedCollectionRows(selectedIds, tbodyId) {
1897
1930
  var tbody = document.getElementById(tbodyId);
@@ -1908,7 +1941,11 @@
1908
1941
  owner: tr.dataset.owner,
1909
1942
  repo: tr.dataset.repo,
1910
1943
  number: tr.dataset.number,
1911
- prUrl: prUrl
1944
+ prUrl: prUrl,
1945
+ // Alt-host rows carry a host; github.com rows leave it undefined. The
1946
+ // single-row click path threads this the same way (see the collection
1947
+ // row click handler) so bulk-open binds to the right system too.
1948
+ host: tr.dataset.host
1912
1949
  });
1913
1950
  }
1914
1951
  }
@@ -1917,7 +1954,14 @@
1917
1954
 
1918
1955
  function buildReviewUrlsFromRows(rows, query) {
1919
1956
  return rows.map(function (row) {
1920
- return '/pr/' + encodeURIComponent(row.owner) + '/' + encodeURIComponent(row.repo) + '/' + row.number + (query || '');
1957
+ // Preserve any existing params (analyze / analysisConfigId) and append the
1958
+ // alt host so setup opens the PR against the system it lives on instead of
1959
+ // re-probing. github.com rows (no host) keep the URL unchanged.
1960
+ var qs = query || '';
1961
+ if (row.host) {
1962
+ qs += (qs ? '&' : '?') + 'host=' + encodeURIComponent(row.host);
1963
+ }
1964
+ return '/pr/' + encodeURIComponent(row.owner) + '/' + encodeURIComponent(row.repo) + '/' + row.number + qs;
1921
1965
  });
1922
1966
  }
1923
1967
 
@@ -2000,10 +2044,18 @@
2000
2044
  };
2001
2045
 
2002
2046
  var providersInfo = await getBulkProvidersInfo();
2003
- var resolvedPair = window.resolveProviderModelPair([
2004
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
2005
- { provider: window.__pairReview?.defaultProvider, model: window.__pairReview?.defaultModel }
2006
- ], providersInfo);
2047
+ // Shape __pairReview into the appConfig fields buildProviderModelScopes reads
2048
+ // so a CLI/env `--provider` override is prepended ahead of repo settings.
2049
+ var bulkAppConfig = {
2050
+ default_provider: window.__pairReview?.defaultProvider,
2051
+ default_model: window.__pairReview?.defaultModel,
2052
+ provider_override: window.__pairReview?.providerOverride || null,
2053
+ model_override: window.__pairReview?.modelOverride || null
2054
+ };
2055
+ var resolvedPair = window.resolveProviderModelPair(
2056
+ window.buildProviderModelScopes(repoSettings, bulkAppConfig),
2057
+ providersInfo
2058
+ );
2007
2059
 
2008
2060
  var config = await bulkAnalysisConfigModal.show({
2009
2061
  currentModel: resolvedPair.model,
@@ -2163,6 +2215,21 @@
2163
2215
  return;
2164
2216
  }
2165
2217
 
2218
+ // Alt-host PR: we already know owner/repo/number and the host it lives
2219
+ // on, so navigate straight to the PR route with the host as a query
2220
+ // param (setup.html forwards it into the setup POST body). This bypasses
2221
+ // the URL-parse round trip, which can't reliably recover the host from a
2222
+ // pasted alt-host html_url. github.com rows (no host) keep the existing
2223
+ // parse-and-submit flow below, byte-identical.
2224
+ var rowHost = collectionRow.dataset.host;
2225
+ if (rowHost) {
2226
+ var o = encodeURIComponent(collectionRow.dataset.owner);
2227
+ var r = encodeURIComponent(collectionRow.dataset.repo);
2228
+ var n = encodeURIComponent(collectionRow.dataset.number);
2229
+ window.location.href = '/pr/' + o + '/' + r + '/' + n + '?host=' + encodeURIComponent(rowHost);
2230
+ return;
2231
+ }
2232
+
2166
2233
  var prUrl = collectionRow.dataset.prUrl;
2167
2234
  if (prUrl) {
2168
2235
  // Switch to PR tab to show loading state (do NOT persist to
@@ -2542,4 +2609,14 @@
2542
2609
  window.__pairReview = window.__pairReview || {};
2543
2610
  window.__pairReview.refreshAnalysisSpinners = fetchAndApplyActiveAnalyses;
2544
2611
 
2612
+ // Test-only exports. In the browser `module` is undefined, so this is a
2613
+ // no-op; under Vitest (CommonJS) it exposes the internal bulk-open helpers
2614
+ // for direct unit testing rather than duplicating their logic in a test.
2615
+ if (typeof module !== 'undefined' && module.exports) {
2616
+ module.exports = {
2617
+ buildReviewUrlsFromRows: buildReviewUrlsFromRows,
2618
+ getSelectedCollectionRows: getSelectedCollectionRows
2619
+ };
2620
+ }
2621
+
2545
2622
  })();
@@ -70,9 +70,17 @@ class LocalManager {
70
70
  await this.loadLocalReview();
71
71
 
72
72
  // Auto-trigger analysis if ?analyze=true is present
73
- const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
73
+ const searchParams = new URLSearchParams(window.location.search);
74
+ const autoAnalyze = searchParams.get('analyze');
74
75
  if (autoAnalyze === 'true' && !window.prManager.isAnalyzing) {
75
76
  const manager = window.prManager;
77
+ // Provider/model override carried on the URL by single-port delegation
78
+ // (the delegated-to server is a different process whose env never saw the
79
+ // CLI flag). Prepended ahead of repo settings in _buildDefaultAnalysisConfig.
80
+ const urlOverride = {
81
+ provider: searchParams.get('provider'),
82
+ model: searchParams.get('model')
83
+ };
76
84
  try {
77
85
  // Prefer a stashed bulk-analysis config (threaded via analysisConfigId).
78
86
  // The CLI uses it to carry --instructions plus the resolved
@@ -97,15 +105,15 @@ class LocalManager {
97
105
  manager.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
98
106
  manager._getAppConfig()
99
107
  ]);
100
- config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
108
+ config = await manager._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig, null, urlOverride);
101
109
  }
102
110
 
103
111
  await this.startLocalAnalysis(null, config);
104
112
  } finally {
105
113
  const cleanUrl = new URL(window.location);
106
- cleanUrl.searchParams.delete('analyze');
107
- cleanUrl.searchParams.delete('analysisConfigId');
108
- cleanUrl.searchParams.delete('council');
114
+ // Strip the whole auto-analyze intent bundle (analyze/analysisConfigId/
115
+ // council/provider/model) so a manual refresh does not replay the intent.
116
+ window.stripAnalyzeParams(cleanUrl);
109
117
  history.replaceState(null, '', cleanUrl);
110
118
  }
111
119
  }
@@ -346,13 +354,15 @@ class LocalManager {
346
354
  const lastCouncilId = reviewSettings.last_council_id;
347
355
 
348
356
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
349
- // are never seeded with a cross-provider model (e.g. gemini + opus), which
357
+ // are never seeded with a cross-provider model (e.g. antigravity + opus), which
350
358
  // would blank the model <select> and be rejected by the backend.
359
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
360
+ // settings so `--provider` seeds the modal as the default selection.
351
361
  const providersInfo = await manager._getProvidersInfo();
352
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
353
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
354
- { provider: appConfig.default_provider, model: appConfig.default_model }
355
- ], providersInfo);
362
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
363
+ window.buildProviderModelScopes(repoSettings, appConfig),
364
+ providersInfo
365
+ );
356
366
 
357
367
  // Determine default tab (priority: localStorage > repo settings > 'single')
358
368
  const tabStorageKey = `pair-review-tab:local-${reviewId}`;
package/public/js/pr.js CHANGED
@@ -634,7 +634,7 @@ class PRManager {
634
634
  * @param {Object} reviewSettings - Review settings from fetchLastReviewSettings()
635
635
  * @returns {Promise<Object>} Config object suitable for startAnalysis / startLocalAnalysis
636
636
  */
637
- async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null) {
637
+ async _buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig = {}, providersInfo = null, urlOverride = null) {
638
638
  const defaultTab = repoSettings?.default_tab || 'single';
639
639
  const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
640
640
 
@@ -642,6 +642,8 @@ class PRManager {
642
642
  // highest priority for council selection. When present we force the council
643
643
  // branch regardless of default_tab/settings, and derive configType from the
644
644
  // council's own type ('council' or 'advanced') rather than the repo default.
645
+ // An explicit council outranks a provider/model override here, matching the
646
+ // backend precedence in resolveReviewConfig where --council beats --provider.
645
647
  const urlSearch = (typeof window !== 'undefined' && window.location && window.location.search) || '';
646
648
  const urlCouncilId = new URLSearchParams(urlSearch).get('council');
647
649
  if (urlCouncilId) {
@@ -676,7 +678,14 @@ class PRManager {
676
678
  }
677
679
  }
678
680
 
679
- if ((defaultTab === 'council' || defaultTab === 'advanced') && councilId) {
681
+ // A CLI/env or delegation-URL provider/model override names a single
682
+ // provider, which is incompatible with a multi-voice council. When an
683
+ // override is active we bypass the repo's *default* council and force the
684
+ // single-provider path so `--provider` is always honored. (An explicit
685
+ // `?council=` above still wins, matching the backend precedence.)
686
+ const overrideActive = window.hasProviderModelOverride(appConfig, urlOverride);
687
+
688
+ if (!overrideActive && (defaultTab === 'council' || defaultTab === 'advanced') && councilId) {
680
689
  // Fetch the full council config so the progress modal can render correctly
681
690
  let councilConfig = null;
682
691
  let councilName = null;
@@ -704,12 +713,14 @@ class PRManager {
704
713
  // Resolve provider and model as a MATCHED pair. Resolving each half
705
714
  // independently (repo || app || hardcoded) can mix a provider from one
706
715
  // scope with a model from another, yielding an invalid pair (e.g.
707
- // gemini/opus) that startAnalysis would forward to the backend as-is.
716
+ // antigravity/opus) that startAnalysis would forward to the backend as-is.
717
+ // buildProviderModelScopes prepends any CLI/env or delegation-URL override
718
+ // ahead of repo settings so `--provider` outranks a repo's saved default.
708
719
  const providers = providersInfo || await this._getProvidersInfo();
709
- const { provider, model } = window.resolveProviderModelPair([
710
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
711
- { provider: appConfig.default_provider, model: appConfig.default_model }
712
- ], providers);
720
+ const { provider, model } = window.resolveProviderModelPair(
721
+ window.buildProviderModelScopes(repoSettings, appConfig, urlOverride),
722
+ providers
723
+ );
713
724
 
714
725
  return {
715
726
  provider,
@@ -750,10 +761,18 @@ class PRManager {
750
761
  * @param {number} prNumber - PR number
751
762
  */
752
763
  async _maybeAutoAnalyze(owner, repo, prNumber) {
753
- const autoAnalyze = new URLSearchParams(window.location.search).get('analyze');
764
+ const searchParams = new URLSearchParams(window.location.search);
765
+ const autoAnalyze = searchParams.get('analyze');
754
766
  if (autoAnalyze === 'true' && !this.isAnalyzing) {
755
767
  this._autoAnalyzeRequested = true;
756
768
  let shouldCleanUrl = true;
769
+ // Provider/model override carried on the URL by single-port delegation
770
+ // (the delegated-to server is a different process whose env never saw the
771
+ // CLI flag). Prepended ahead of repo settings in _buildDefaultAnalysisConfig.
772
+ const urlOverride = {
773
+ provider: searchParams.get('provider'),
774
+ model: searchParams.get('model')
775
+ };
757
776
  try {
758
777
  // Skip refresh if we just loaded fresh data (loadPR sets _justLoaded = true).
759
778
  // Otherwise, refresh to ensure we have the latest PR data in case the worktree
@@ -791,7 +810,7 @@ class PRManager {
791
810
  this.fetchLastReviewSettings().catch(() => ({ custom_instructions: '', last_council_id: null })),
792
811
  this._getAppConfig()
793
812
  ]);
794
- config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig);
813
+ config = await this._buildDefaultAnalysisConfig(repoSettings, reviewSettings, appConfig, null, urlOverride);
795
814
  }
796
815
 
797
816
  await this.startAnalysis(owner, repo, prNumber, null, config);
@@ -799,9 +818,9 @@ class PRManager {
799
818
  this._autoAnalyzeRequested = false;
800
819
  if (shouldCleanUrl) {
801
820
  const cleanUrl = new URL(window.location);
802
- cleanUrl.searchParams.delete('analyze');
803
- cleanUrl.searchParams.delete('analysisConfigId');
804
- cleanUrl.searchParams.delete('council');
821
+ // Strip the whole auto-analyze intent bundle (analyze/analysisConfigId/
822
+ // council/provider/model) so a manual refresh does not replay the intent.
823
+ window.stripAnalyzeParams(cleanUrl);
805
824
  history.replaceState(null, '', cleanUrl);
806
825
  }
807
826
  }
@@ -3053,13 +3072,15 @@ class PRManager {
3053
3072
  ]);
3054
3073
 
3055
3074
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
3056
- // are never seeded with a cross-provider model (e.g. gemini + opus), which
3075
+ // are never seeded with a cross-provider model (e.g. antigravity + opus), which
3057
3076
  // would blank the model <select> and be rejected by the backend.
3077
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
3078
+ // settings so `--provider` seeds the modal as the default selection.
3058
3079
  const providersInfo = await this._getProvidersInfo();
3059
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
3060
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
3061
- { provider: appConfig.default_provider, model: appConfig.default_model }
3062
- ], providersInfo);
3080
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
3081
+ window.buildProviderModelScopes(repoSettings, appConfig),
3082
+ providersInfo
3083
+ );
3063
3084
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
3064
3085
  const rememberedTab = localStorage.getItem(tabStorageKey);
3065
3086
  const defaultTab = rememberedTab || repoSettings?.default_tab || 'single';
@@ -6846,13 +6867,15 @@ class PRManager {
6846
6867
  const lastCouncilId = reviewSettings.last_council_id;
6847
6868
 
6848
6869
  // Resolve provider and model as a MATCHED pair so the council/advanced tabs
6849
- // are never seeded with a cross-provider model (e.g. gemini + opus), which
6870
+ // are never seeded with a cross-provider model (e.g. antigravity + opus), which
6850
6871
  // would blank the model <select> and be rejected by the backend.
6872
+ // buildProviderModelScopes prepends any CLI/env override ahead of repo
6873
+ // settings so `--provider` seeds the modal as the default selection.
6851
6874
  const providersInfo = await this._getProvidersInfo();
6852
- const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair([
6853
- { provider: repoSettings?.default_provider, model: repoSettings?.default_model },
6854
- { provider: appConfig.default_provider, model: appConfig.default_model }
6855
- ], providersInfo);
6875
+ const { provider: currentProvider, model: currentModel } = window.resolveProviderModelPair(
6876
+ window.buildProviderModelScopes(repoSettings, appConfig),
6877
+ providersInfo
6878
+ );
6856
6879
 
6857
6880
  // Determine default tab (priority: localStorage > repo settings > 'single')
6858
6881
  const tabStorageKey = PRManager.getRepoStorageKey('pair-review-tab', owner, repo);
@@ -7021,29 +7044,34 @@ class PRManager {
7021
7044
 
7022
7045
  /**
7023
7046
  * Build the worktree-not-found recovery URL. When the user arrived via
7024
- * auto-analyze (?analyze=true), the reload link preserves the auto-analyze
7025
- * state so analysis re-triggers after worktree setup. Both the
7026
- * `analysisConfigId` and `council` params are carried through, since
7027
- * `_buildDefaultAnalysisConfig()` treats `?council=<id>` as the
7028
- * highest-priority analysis source dropping it would silently fall back to
7029
- * the repo/default analysis configuration on retry.
7047
+ * auto-analyze (?analyze=true), the reload link preserves the whole
7048
+ * auto-analyze intent bundle (analyze/analysisConfigId/council/provider/model)
7049
+ * so analysis re-triggers with the same selection after worktree setup.
7050
+ * Carrying is delegated to the shared `carryAnalyzeParams` relay so this
7051
+ * hop stays in lockstep with the other three (see analyze-params.js);
7052
+ * dropping `council` would silently fall back to the repo/default analysis
7053
+ * config, and dropping `provider`/`model` would lose a `--provider` override.
7030
7054
  * @param {string} owner - Repository owner
7031
7055
  * @param {string} repo - Repository name
7032
7056
  * @param {number} number - PR number
7033
7057
  * @returns {string} The recovery URL (unescaped)
7034
7058
  */
7035
7059
  _buildWorktreeRecoveryUrl(owner, repo, number) {
7036
- let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
7060
+ // A fixed base is fine — only pathname + search are used for the link, so
7061
+ // the origin is irrelevant (and this avoids depending on window.location).
7062
+ const setupUrl = new URL(
7063
+ `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`,
7064
+ 'http://localhost'
7065
+ );
7037
7066
  if (this._autoAnalyzeRequested) {
7038
- const currentParams = new URLSearchParams(window.location.search);
7039
- const params = new URLSearchParams({ analyze: 'true' });
7040
- const analysisConfigId = currentParams.get('analysisConfigId');
7041
- const councilId = currentParams.get('council');
7042
- if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
7043
- if (councilId) params.set('council', councilId);
7044
- setupUrl += `?${params.toString()}`;
7067
+ // The current search still carries the bundle here — it isn't stripped
7068
+ // until _maybeAutoAnalyze's finally block, which runs after this handler.
7069
+ // Force analyze=true so the retry still auto-triggers even if the source
7070
+ // somehow lost it, then carry the rest of the bundle via the shared relay.
7071
+ setupUrl.searchParams.set('analyze', 'true');
7072
+ window.carryAnalyzeParams(window.location.search, setupUrl);
7045
7073
  }
7046
- return setupUrl;
7074
+ return setupUrl.pathname + setupUrl.search;
7047
7075
  }
7048
7076
 
7049
7077
  /**
@@ -7055,14 +7083,14 @@ class PRManager {
7055
7083
  * @param {number} number - PR number
7056
7084
  */
7057
7085
  showWorktreeNotFoundError(owner, repo, number) {
7058
- const setupUrl = this._buildWorktreeRecoveryUrl(owner, repo, number);
7086
+ const href = this._buildWorktreeRecoveryUrl(owner, repo, number);
7059
7087
  const container = document.getElementById('pr-container');
7060
7088
  if (container) {
7061
7089
  container.innerHTML = `
7062
7090
  <div class="error-container">
7063
7091
  <div class="error-icon">Warning</div>
7064
7092
  <div class="error-message">Worktree not found. Please reload the PR to set up the worktree before running analysis.</div>
7065
- <a class="btn btn-primary" href="${this.escapeHtml(setupUrl)}">Reload PR</a>
7093
+ <a class="btn btn-primary" href="${this.escapeHtml(href)}">Reload PR</a>
7066
7094
  </div>
7067
7095
  `;
7068
7096
  container.style.display = 'block';
@@ -246,9 +246,17 @@
246
246
  _currentLinks = null;
247
247
  _currentContext = context || null;
248
248
  try {
249
- const response = await fetch(
250
- '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links'
251
- );
249
+ // Pass the PR number (when known) so the server can resolve the link set
250
+ // against this PR's host for a dual-host repo (github links for a
251
+ // github-hosted PR, the external link for an alt-hosted one). Local mode
252
+ // has no PR number, so the query is omitted and the server returns the
253
+ // repo-level default.
254
+ let url = '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links';
255
+ const number = context && context.number;
256
+ if (number !== undefined && number !== null && number !== '') {
257
+ url += '?number=' + encodeURIComponent(String(number));
258
+ }
259
+ const response = await fetch(url);
252
260
  if (!response.ok) {
253
261
  console.warn('[repo-links] Failed to fetch repo links:', response.status);
254
262
  return;
@@ -0,0 +1,69 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared "auto-analyze intent" URL-param relay.
4
+ *
5
+ * The params `analyze`, `analysisConfigId`, `council`, `provider`, and `model`
6
+ * form a single bundle that must travel TOGETHER through every browser hop
7
+ * between the single-port delegation URL and the review page that consumes them:
8
+ *
9
+ * delegation URL → setup.html redirect(s) → review page auto-analyze
10
+ * review page → "Reload PR" retry → setup.html → review page auto-analyze
11
+ *
12
+ * Each hop used to cherry-pick fields by name, so any hop that forgot one
13
+ * silently dropped it — which is exactly how a delegated `--provider` override
14
+ * got lost on its way to the running server (and how a `--council` selection
15
+ * would drop on the "Reload PR" retry). Centralizing the bundle here means
16
+ * adding a new intent param later is a one-line edit in ONE place instead of a
17
+ * fresh bug at every call site.
18
+ *
19
+ * Note: setup-internal params (e.g. `path`, consumed by the local-mode setup
20
+ * POST) are deliberately NOT in the bundle, so they never leak onto the review
21
+ * page URL.
22
+ */
23
+ const ANALYZE_PARAM_KEYS = ['analyze', 'analysisConfigId', 'council', 'provider', 'model'];
24
+
25
+ /**
26
+ * Copy the auto-analyze intent bundle from a source query onto a target URL.
27
+ * Only non-empty values present in the source are copied; other params already
28
+ * on `toUrl` are left untouched.
29
+ *
30
+ * @param {URLSearchParams|string} fromSearch - source query (e.g. window.location.search)
31
+ * @param {URL} toUrl - destination URL object, mutated in place
32
+ * @returns {URL} the same `toUrl`, for chaining
33
+ */
34
+ function carryAnalyzeParams(fromSearch, toUrl) {
35
+ if (!toUrl) return toUrl;
36
+ const src = typeof fromSearch === 'string' ? new URLSearchParams(fromSearch) : fromSearch;
37
+ if (!src || typeof src.get !== 'function') return toUrl;
38
+ for (const key of ANALYZE_PARAM_KEYS) {
39
+ const value = src.get(key);
40
+ if (value !== null && value !== '') {
41
+ toUrl.searchParams.set(key, value);
42
+ }
43
+ }
44
+ return toUrl;
45
+ }
46
+
47
+ /**
48
+ * Delete the auto-analyze intent bundle from a URL. Used by the consumer after
49
+ * it acts on the intent, so a manual page refresh does not replay it.
50
+ *
51
+ * @param {URL} url - URL object, mutated in place
52
+ * @returns {URL} the same `url`, for chaining
53
+ */
54
+ function stripAnalyzeParams(url) {
55
+ if (!url) return url;
56
+ for (const key of ANALYZE_PARAM_KEYS) {
57
+ url.searchParams.delete(key);
58
+ }
59
+ return url;
60
+ }
61
+
62
+ if (typeof window !== 'undefined') {
63
+ window.carryAnalyzeParams = carryAnalyzeParams;
64
+ window.stripAnalyzeParams = stripAnalyzeParams;
65
+ }
66
+
67
+ if (typeof module !== 'undefined' && module.exports) {
68
+ module.exports = { carryAnalyzeParams, stripAnalyzeParams, ANALYZE_PARAM_KEYS };
69
+ }
@@ -6,7 +6,7 @@
6
6
  * settings, then app config) and historically each half was resolved
7
7
  * independently with `repo || app || hardcoded`. When a scope overrides only
8
8
  * ONE half, the halves can come from different scopes and produce an invalid
9
- * pair such as `gemini` + `opus` (an Anthropic model). The modal then shows no
9
+ * pair such as `antigravity` + `opus` (an Anthropic model). The modal then shows no
10
10
  * selected model, and the non-modal auto-analyze path posts the broken pair
11
11
  * straight to the backend.
12
12
  *
@@ -86,10 +86,75 @@ function resolveProviderModelPair(scopes, providersInfo) {
86
86
  return { provider: 'claude', model: providerDefaultModel(claude) };
87
87
  }
88
88
 
89
+ /**
90
+ * Build the ordered scope list for resolveProviderModelPair, injecting the
91
+ * CLI/env override AHEAD of saved repo settings.
92
+ *
93
+ * The override must outrank repo settings to honor the documented
94
+ * `CLI/env > repo settings` contract. Folding the override into appConfig's
95
+ * default_provider/default_model alone is NOT enough: every seed site passes
96
+ * repoSettings first, so an env-aware appConfig would still lose to a repo's
97
+ * saved default. Prepending the override scope is the only correct fix.
98
+ *
99
+ * Two override channels, highest precedence first:
100
+ * 1. `extraOverride` — a per-invocation override carried on the auto-analyze
101
+ * URL (single-port delegation: the flag can only reach the already-running
102
+ * server through the URL).
103
+ * 2. `appConfig.provider_override` / `appConfig.model_override` — the
104
+ * PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL env override surfaced by
105
+ * /api/config, for the process that actually received the CLI flag.
106
+ *
107
+ * A provider-only override (`{ provider: 'codex', model: null }`) resolves via
108
+ * resolveProviderModelPair to codex + codex's own default model, matching CLI
109
+ * semantics where `--provider codex` alone pins the provider.
110
+ *
111
+ * @param {Object} repoSettings - saved repo settings row (may be null)
112
+ * @param {Object} [appConfig] - /api/config response (or __pairReview-shaped equivalent)
113
+ * @param {{provider?: string, model?: string}} [extraOverride] - per-invocation override (URL params)
114
+ * @returns {Array<{provider?: string, model?: string}>} ordered scopes for resolveProviderModelPair
115
+ */
116
+ function buildProviderModelScopes(repoSettings, appConfig = {}, extraOverride = null) {
117
+ const cfg = appConfig || {};
118
+ const scopes = [];
119
+ // 1. Per-invocation override (delegation URL params) — highest precedence.
120
+ if (extraOverride && (extraOverride.provider || extraOverride.model)) {
121
+ scopes.push({ provider: extraOverride.provider || null, model: extraOverride.model || null });
122
+ }
123
+ // 2. CLI/env override surfaced by /api/config (non-delegated invocations).
124
+ if (cfg.provider_override || cfg.model_override) {
125
+ scopes.push({ provider: cfg.provider_override || null, model: cfg.model_override || null });
126
+ }
127
+ // 3. Saved repo settings.
128
+ scopes.push({ provider: repoSettings?.default_provider, model: repoSettings?.default_model });
129
+ // 4. App/config defaults.
130
+ scopes.push({ provider: cfg.default_provider, model: cfg.default_model });
131
+ return scopes;
132
+ }
133
+
134
+ /**
135
+ * Whether a CLI/env provider-or-model override is active (from either channel).
136
+ * Used to decide whether auto-analyze should bypass a council default and force
137
+ * the single-provider path (a single-provider override is incompatible with a
138
+ * multi-voice council).
139
+ *
140
+ * @param {Object} [appConfig] - /api/config response (or __pairReview-shaped equivalent)
141
+ * @param {{provider?: string, model?: string}} [extraOverride] - per-invocation override (URL params)
142
+ * @returns {boolean}
143
+ */
144
+ function hasProviderModelOverride(appConfig = {}, extraOverride = null) {
145
+ const cfg = appConfig || {};
146
+ return !!(
147
+ (extraOverride && (extraOverride.provider || extraOverride.model)) ||
148
+ cfg.provider_override || cfg.model_override
149
+ );
150
+ }
151
+
89
152
  if (typeof window !== 'undefined') {
90
153
  window.resolveProviderModelPair = resolveProviderModelPair;
154
+ window.buildProviderModelScopes = buildProviderModelScopes;
155
+ window.hasProviderModelOverride = hasProviderModelOverride;
91
156
  }
92
157
 
93
158
  if (typeof module !== 'undefined' && module.exports) {
94
- module.exports = { resolveProviderModelPair };
159
+ module.exports = { resolveProviderModelPair, buildProviderModelScopes, hasProviderModelOverride };
95
160
  }