@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
@@ -11,13 +11,14 @@
11
11
  * - setupPRReview: full orchestrator that wires the above together
12
12
  */
13
13
 
14
- const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository } = require('../database');
14
+ const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository, PRMetadataRepository } = require('../database');
15
15
  const { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE } = require('../git/worktree');
16
16
  const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
17
17
  const { GitHubClient } = require('../github/client');
18
18
  const { normalizeRepository } = require('../utils/paths');
19
19
  const { findMainGitRoot } = require('../local-review');
20
- const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveHostBinding, resolveBindingRepositoryFromPR, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
20
+ const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveHostBinding, resolveBindingRepositoryFromPR, getRepoConfig, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
21
+ const { storedHostToOption, isDualHostRepoConfig } = require('../utils/host-resolution');
21
22
  const logger = require('../utils/logger');
22
23
  const { fireReviewStartedHook } = require('../hooks/payloads');
23
24
  const simpleGit = require('simple-git');
@@ -38,14 +39,74 @@ const path = require('path');
38
39
  * @param {string} worktreePath - Worktree (or checkout) path
39
40
  * @param {Object} [options] - Optional settings
40
41
  * @param {boolean} [options.skipWorktreeRecord] - Skip creating a worktree DB record
42
+ * @param {string|null} [options.host] - Per-PR host binding to stamp. When
43
+ * omitted (`undefined`), the host column is left untouched on UPDATE and
44
+ * written as NULL on INSERT. `null` (github.com) or an api_host URL string is
45
+ * written on both the UPDATE and INSERT arms — this is the self-healing step
46
+ * that stamps the host actually used to fetch the PR.
41
47
  */
42
48
  async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, options = {}) {
43
49
  const repository = normalizeRepository(prInfo.owner, prInfo.repo);
50
+ // `undefined` means "caller doesn't know the host" — don't disturb any stored
51
+ // value on UPDATE and default to NULL (github.com) on INSERT. `null` or a URL
52
+ // string is an explicit host the caller wants persisted on both arms.
53
+ const writeHost = options.host !== undefined;
54
+ const hostValue = options.host;
44
55
 
45
56
  // Begin transaction for atomic database operations
46
57
  await run(db, 'BEGIN TRANSACTION');
47
58
 
48
59
  try {
60
+ // Look up any existing row FIRST — before any mutation — so the cross-host
61
+ // collision guard below can reject cleanly (no partial write to roll back).
62
+ const existingPR = await queryOne(db, `
63
+ SELECT id, host, pr_data FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
64
+ `, [prInfo.number, repository]);
65
+
66
+ // Cross-host collision guard.
67
+ //
68
+ // INVARIANT: a given (repository, pr_number) identifies exactly ONE pull
69
+ // request across ALL hosts. This is the confirmed plan assumption
70
+ // (plans/per-pr-host-resolution.md, "Assumption (confirmed)") — PR numbers do
71
+ // NOT collide between github.com and an alt host for the same repo — and it
72
+ // is why durable identity stays keyed UNIQUE(pr_number, repository) with no
73
+ // host column and the /pr/:owner/:repo/:number URL scheme is unchanged.
74
+ //
75
+ // If that assumption is ever violated (two DIFFERENT PRs share a number on
76
+ // two hosts), the UPDATE arm below would silently overwrite the first PR's
77
+ // pr_data and re-point its reviews/worktrees at the second. Detect it: when
78
+ // this fetch's host differs from the stored row's host, the API id must
79
+ // match. Same id → same logical PR, the host difference is the intended
80
+ // self-heal relabel → proceed. Different id → genuine cross-host duplicate →
81
+ // refuse rather than corrupt the wrong PR. Unparseable stored id → proceed
82
+ // but warn (can't prove either way; nothing to compare against).
83
+ if (existingPR && writeHost && existingPR.host !== hostValue) {
84
+ const incomingId = prData.id ?? prData.node_id ?? null;
85
+ let storedId = null;
86
+ try {
87
+ const storedData = existingPR.pr_data ? JSON.parse(existingPR.pr_data) : null;
88
+ if (storedData) storedId = storedData.id ?? storedData.node_id ?? null;
89
+ } catch {
90
+ storedId = null; // unparseable → treated as "unknown" below
91
+ }
92
+ const storedHostLabel = existingPR.host === null ? 'github.com' : existingPR.host;
93
+ const incomingHostLabel = hostValue === null ? 'github.com' : hostValue;
94
+ if (storedId === null) {
95
+ logger.warn(
96
+ `storePRData: stored pr_metadata for ${repository} #${prInfo.number} has no parseable API id; ` +
97
+ `proceeding with host relabel (${storedHostLabel} -> ${incomingHostLabel})`
98
+ );
99
+ } else if (incomingId !== null && String(storedId) !== String(incomingId)) {
100
+ throw new Error(
101
+ `Cross-host PR conflict for ${repository} #${prInfo.number}: a DIFFERENT pull request with this ` +
102
+ `number is already stored on ${storedHostLabel}, but this fetch came from ${incomingHostLabel}. ` +
103
+ `pair-review assumes pull request numbers do not collide across hosts for a repository ` +
104
+ `(see plans/per-pr-host-resolution.md); reviewing two different PRs that share number ` +
105
+ `#${prInfo.number} on different hosts is not supported.`
106
+ );
107
+ }
108
+ }
109
+
49
110
  // Store or update worktree record (skip when using --use-checkout,
50
111
  // since the path is the user's working directory, not a managed worktree)
51
112
  if (!options.skipWorktreeRecord) {
@@ -67,19 +128,16 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
67
128
  fetched_at: new Date().toISOString()
68
129
  };
69
130
 
70
- // First check if PR metadata exists
71
- const existingPR = await queryOne(db, `
72
- SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
73
- `, [prInfo.number, repository]);
74
-
75
131
  const now = new Date().toISOString();
76
132
 
77
133
  if (existingPR) {
78
- // Update existing PR metadata (preserves ID)
134
+ // Update existing PR metadata (preserves ID). The host column is only
135
+ // touched when the caller supplies one, so a plain re-fetch that doesn't
136
+ // know the host leaves any previously stamped value intact.
79
137
  await run(db, `
80
138
  UPDATE pr_metadata
81
139
  SET title = ?, description = ?, author = ?,
82
- base_branch = ?, head_branch = ?, pr_data = ?,
140
+ base_branch = ?, head_branch = ?, pr_data = ?${writeHost ? ', host = ?' : ''},
83
141
  updated_at = CURRENT_TIMESTAMP, last_accessed_at = ?
84
142
  WHERE id = ?
85
143
  `, [
@@ -89,16 +147,18 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
89
147
  prData.base_branch,
90
148
  prData.head_branch,
91
149
  JSON.stringify(extendedPRData),
150
+ ...(writeHost ? [hostValue] : []),
92
151
  now,
93
152
  existingPR.id
94
153
  ]);
95
154
  logger.info(`Updated existing PR metadata (ID: ${existingPR.id})`);
96
155
  } else {
97
- // Insert new PR metadata
156
+ // Insert new PR metadata. When no host is supplied the column is omitted
157
+ // and defaults to NULL (github.com).
98
158
  const result = await run(db, `
99
159
  INSERT INTO pr_metadata
100
- (pr_number, repository, title, description, author, base_branch, head_branch, pr_data, last_accessed_at)
101
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
160
+ (pr_number, repository, title, description, author, base_branch, head_branch, pr_data${writeHost ? ', host' : ''}, last_accessed_at)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?${writeHost ? ', ?' : ''}, ?)
102
162
  `, [
103
163
  prInfo.number,
104
164
  repository,
@@ -108,6 +168,7 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
108
168
  prData.base_branch,
109
169
  prData.head_branch,
110
170
  JSON.stringify(extendedPRData),
171
+ ...(writeHost ? [hostValue] : []),
111
172
  now
112
173
  ]);
113
174
  logger.info(`Created new PR metadata (ID: ${result.lastID})`);
@@ -173,6 +234,122 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
173
234
  }
174
235
  }
175
236
 
237
+ /**
238
+ * Resolve the per-PR host binding and fetch the PR, applying the host
239
+ * precedence used by every PR-setup entry point (web setup route + CLI):
240
+ *
241
+ * 1. explicit `host` (URL paste / request body): `undefined` = unknown,
242
+ * `null` = github.com, `'<url>'` = an alt host. When defined,
243
+ * `resolveHostBinding` validates it and throws on a stale/mismatched host.
244
+ * 2. stored `pr_metadata.host` (a row exists → `getPRHost !== undefined`).
245
+ * 3. ambiguity rule. For a DUAL repo (`api_host` + `exclusive: false`) whose
246
+ * host is still unknown, PROBE: fetch from the alt host first and, ONLY on
247
+ * a 404, fall back to github.com. Any non-404 error (auth, network, 5xx)
248
+ * fails loudly WITHOUT falling back — a silent fallback could fetch a
249
+ * same-numbered PR from the wrong system and stamp the wrong host.
250
+ * Exclusive alt-host and plain github repos resolve to a single binding
251
+ * with no probe (byte-identical to pre-dual behaviour).
252
+ *
253
+ * The chosen binding carries a `host` echo field (null for github.com, the
254
+ * api_host string for an alt host) that callers persist via `storePRData` — the
255
+ * self-healing step that stamps the host actually used.
256
+ *
257
+ * @param {Object} params
258
+ * @param {Object} params.db - Database instance (for the stored-host lookup)
259
+ * @param {Object} params.config - Application config
260
+ * @param {string} params.bindingRepository - `repos[...]` config-lookup key
261
+ * @param {string} params.owner
262
+ * @param {string} params.repo
263
+ * @param {number} params.prNumber
264
+ * @param {string|null|undefined} params.host - explicit host override (see above)
265
+ * @param {string} [params.githubToken] - fallback token when the chosen binding
266
+ * resolves none (legacy github.com behaviour)
267
+ * @param {Object} [params.deps] - `{ GitHubClient }` override for tests
268
+ * @returns {Promise<{ binding: Object, prData: Object, githubClient: Object }>}
269
+ */
270
+ async function resolvePrHostBinding({ db, config, bindingRepository, owner, repo, prNumber, host, githubToken, deps = {} }) {
271
+ const Client = deps.GitHubClient || GitHubClient;
272
+ const repository = normalizeRepository(owner, repo);
273
+ const repoConfig = getRepoConfig(config, bindingRepository);
274
+ const configuredApiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
275
+ ? repoConfig.api_host
276
+ : null;
277
+ // A dual repo has an api_host but is not exclusive — its PRs may live on
278
+ // github.com OR the alt host, so an unknown host must be probed.
279
+ const isDual = isDualHostRepoConfig(repoConfig);
280
+
281
+ // Build the argument for `new Client(...)` from a resolved binding. A binding
282
+ // with a token is used as-is. A github-flavored binding (apiHost === null) with
283
+ // no token may fall back to the caller's github.com token (legacy behaviour).
284
+ // An ALT-flavored binding with no token must NOT fall back to `githubToken` —
285
+ // that would point Octokit at api.github.com while the caller records the
286
+ // result as the alt host (stamping a same-numbered github PR as alt). Guard on
287
+ // host flavor, not token truthiness: surface a clear missing-credential error.
288
+ const clientArgFor = (binding) => {
289
+ if (binding.token) return binding;
290
+ if (binding.apiHost === null) return githubToken;
291
+ throw new Error(
292
+ `No token configured for alt host ${binding.apiHost} (repo ${bindingRepository}). ` +
293
+ `Configure repos["${bindingRepository}"].token or token_command.`
294
+ );
295
+ };
296
+
297
+ // Apply host precedence to decide whether we know the host up front.
298
+ let effectiveHost;
299
+ let hostKnown = false;
300
+ if (host !== undefined) {
301
+ effectiveHost = host;
302
+ hostKnown = true;
303
+ } else {
304
+ const prMetadataRepo = new PRMetadataRepository(db);
305
+ const storedHost = await prMetadataRepo.getPRHost(repository, prNumber);
306
+ // storedHostToOption applies the legacy-NULL convention: `undefined` means
307
+ // "host unknown" (leave hostKnown false → ambiguity/probe path), while a
308
+ // returned option object pins the host.
309
+ const storedOption = storedHostToOption(config, bindingRepository, storedHost);
310
+ if (storedOption !== undefined) {
311
+ effectiveHost = storedOption.host;
312
+ hostKnown = true;
313
+ }
314
+ }
315
+
316
+ // Fixed-binding path: host is known, OR the repo can only live on one host
317
+ // (plain github / exclusive alt) so the ambiguity rule is unambiguous.
318
+ if (hostKnown || !isDual) {
319
+ const binding = resolveHostBinding(bindingRepository, config, hostKnown ? { host: effectiveHost } : {});
320
+ const client = new Client(clientArgFor(binding));
321
+ const repoExists = await client.repositoryExists(owner, repo);
322
+ if (!repoExists) {
323
+ throw new Error(`Repository ${owner}/${repo} not found`);
324
+ }
325
+ const prData = await client.fetchPullRequest(owner, repo, prNumber);
326
+ return { binding, prData, githubClient: client };
327
+ }
328
+
329
+ // Probe path: dual repo, host unknown. Try the alt host first. `clientArgFor`
330
+ // errors clearly if the alt binding has no token rather than silently probing
331
+ // github.com disguised as the alt host.
332
+ const altBinding = resolveHostBinding(bindingRepository, config, { host: configuredApiHost });
333
+ const altClient = new Client(clientArgFor(altBinding));
334
+ try {
335
+ const prData = await altClient.fetchPullRequest(owner, repo, prNumber);
336
+ logger.info(`PR #${prNumber} (${repository}) resolved to alt host ${configuredApiHost}`);
337
+ return { binding: altBinding, prData, githubClient: altClient };
338
+ } catch (altErr) {
339
+ if (altErr && altErr.status === 404) {
340
+ logger.info(`PR #${prNumber} (${repository}) not found on alt host ${configuredApiHost}; falling back to github.com`);
341
+ const githubBinding = resolveHostBinding(bindingRepository, config, { host: null });
342
+ const githubClient = new Client(clientArgFor(githubBinding));
343
+ const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
344
+ return { binding: githubBinding, prData, githubClient };
345
+ }
346
+ // Auth/network/5xx on the alt host — do NOT fall back to github.com.
347
+ throw new Error(
348
+ `Failed to fetch PR #${prNumber} from alt host ${configuredApiHost}: ${altErr.message}`
349
+ );
350
+ }
351
+ }
352
+
176
353
  /**
177
354
  * Register the known location of a GitHub repository in the database.
178
355
  * This allows the web UI to find the repo without cloning when reviewing PRs.
@@ -419,30 +596,30 @@ function isShaNotFoundError(err) {
419
596
  * @param {Object} [params.config] - Application config (for monorepo path lookup)
420
597
  * @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [params.poolLifecycle] - Shared pool lifecycle instance (avoids creating a fresh singleton)
421
598
  * @param {Object} [params.restoreMetadata] - Stored PR data for restore mode (skips GitHub fetch + diff)
599
+ * @param {string|null} [params.host] - Explicit per-PR host override (URL paste /
600
+ * dashboard row): `null` = github.com, an api_host URL string = that alt host,
601
+ * omitted (`undefined`) = unknown (derive from stored host / probe).
422
602
  * @param {Function} [params.onProgress] - Optional progress callback
423
603
  * @returns {Promise<{ reviewUrl: string, title: string }>}
424
604
  */
425
- async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRepository: externalBindingRepository, config, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
605
+ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRepository: externalBindingRepository, config, host, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
426
606
  const repository = normalizeRepository(owner, repo);
427
607
  const progress = onProgress || (() => {});
428
608
 
429
- // Resolve the per-repo host binding so alt-host setups talk to the
430
- // configured `api_host`. Use `resolveBindingRepositoryFromPR` so
431
- // monorepo-style configs (one `repos[...]` entry serving many
432
- // captured owner/repo) find the right binding. Fall back to the bare
433
- // token shape if no config is available (legacy invocation path) or
434
- // the binding resolved no token (callers in this case already
435
- // pre-resolved it).
609
+ // Resolve the config-lookup key. Use `resolveBindingRepositoryFromPR` so
610
+ // monorepo-style configs (one `repos[...]` entry serving many captured
611
+ // owner/repo) find the right binding. Fall back to the PR identity when no
612
+ // config is available (legacy invocation path).
436
613
  const bindingRepository = externalBindingRepository
437
614
  || (config ? resolveBindingRepositoryFromPR(owner, repo, config) : repository);
438
- const setupBinding = config ? resolveHostBinding(bindingRepository, config) : null;
439
- const clientArg = (setupBinding && setupBinding.token)
440
- ? setupBinding
441
- : githubToken;
442
615
 
443
616
  const isRestore = !!(restoreMetadata && restoreMetadata.head_sha);
444
617
  let prData;
445
618
  let githubClient = null;
619
+ // Host actually used to fetch — persisted by storePRData (self-healing). Left
620
+ // undefined for restore mode and the legacy (no-config) path so those flows
621
+ // leave any previously-stamped host untouched.
622
+ let stampHost;
446
623
 
447
624
  if (isRestore) {
448
625
  prData = restoreMetadata;
@@ -450,21 +627,33 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
450
627
  progress({ step: 'fetch', status: 'completed', message: 'Using stored PR data.' });
451
628
  } else {
452
629
  // ------------------------------------------------------------------
453
- // Step: verify - Verify repository access
630
+ // Step: verify + fetch - Resolve the per-PR host binding, verify access,
631
+ // and fetch PR data. `resolvePrHostBinding` applies host precedence
632
+ // (explicit host → stored host → ambiguity/probe) and, for a dual repo
633
+ // whose host is unknown, probes the alt host first (404 → github.com).
454
634
  // ------------------------------------------------------------------
455
635
  progress({ step: 'verify', status: 'running', message: 'Verifying repository access...' });
456
- githubClient = new GitHubClient(clientArg);
457
- const repoExists = await githubClient.repositoryExists(owner, repo);
458
- if (!repoExists) {
459
- throw new Error(`Repository ${owner}/${repo} not found`);
636
+ // Pair the 'fetch' step's completed event (below) with a running event and
637
+ // restore the spinner message; resolvePrHostBinding does the actual fetch.
638
+ progress({ step: 'fetch', status: 'running', message: 'Fetching pull request data from GitHub...' });
639
+ if (config) {
640
+ const resolved = await resolvePrHostBinding({
641
+ db, config, bindingRepository, owner, repo, prNumber, host, githubToken
642
+ });
643
+ githubClient = resolved.githubClient;
644
+ prData = resolved.prData;
645
+ stampHost = resolved.binding.host;
646
+ } else {
647
+ // Legacy path: no config, so bind directly with the supplied token and
648
+ // do not track a host.
649
+ githubClient = new GitHubClient(githubToken);
650
+ const repoExists = await githubClient.repositoryExists(owner, repo);
651
+ if (!repoExists) {
652
+ throw new Error(`Repository ${owner}/${repo} not found`);
653
+ }
654
+ prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
460
655
  }
461
656
  progress({ step: 'verify', status: 'completed', message: 'Repository access verified.' });
462
-
463
- // ------------------------------------------------------------------
464
- // Step: fetch - Fetch PR data from GitHub
465
- // ------------------------------------------------------------------
466
- progress({ step: 'fetch', status: 'running', message: 'Fetching pull request data from GitHub...' });
467
- prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
468
657
  progress({ step: 'fetch', status: 'completed', message: 'Pull request data fetched.' });
469
658
  }
470
659
 
@@ -624,7 +813,9 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
624
813
  // Step: store - Persist PR data and register repository location
625
814
  // ------------------------------------------------------------------
626
815
  progress({ step: 'store', status: 'running', message: 'Storing pull request data...' });
627
- const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
816
+ const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
817
+ host: stampHost
818
+ });
628
819
 
629
820
  // Persist review→worktree mapping in DB for pool usage tracking
630
821
  if (poolWorktreeId) {
@@ -662,8 +853,15 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
662
853
  // fall back to a full fresh setup.
663
854
  if (isRestore && isShaNotFoundError(err)) {
664
855
  logger.warn(`Restore to stored SHA failed, falling back to fresh setup: ${err.message}`);
665
- // Retry without restoreMetadata.
666
- return setupPRReview({ db, owner, repo, prNumber, githubToken, config, onProgress, poolLifecycle: externalPoolLifecycle });
856
+ // Retry without restoreMetadata. Forward the explicit `host` and
857
+ // `bindingRepository` so the fresh fetch binds the SAME host the caller
858
+ // requested — re-deriving them here could bind a different host (probe a
859
+ // dual repo, or pick the wrong monorepo binding key).
860
+ return setupPRReview({
861
+ db, owner, repo, prNumber, githubToken,
862
+ bindingRepository: externalBindingRepository,
863
+ config, host, onProgress, poolLifecycle: externalPoolLifecycle
864
+ });
667
865
  }
668
866
 
669
867
  // Release the pool worktree so it doesn't stay permanently in_use.
@@ -684,4 +882,4 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
684
882
  }
685
883
  }
686
884
 
687
- module.exports = { setupPRReview, storePRData, registerRepositoryLocation, findRepositoryPath, isShaNotFoundError };
885
+ module.exports = { setupPRReview, storePRData, resolvePrHostBinding, registerRepositoryLocation, findRepositoryPath, isShaNotFoundError };
@@ -90,9 +90,14 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
90
90
  // 5. Get changed files with stats
91
91
  const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
92
92
 
93
- // 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
93
+ // 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records).
94
+ // Stamp the host of the binding used to fetch this stack PR (null for
95
+ // github.com, the api_host string for an alt host) so per-PR host resolution
96
+ // self-heals across the stack. Omitted when only a bare token was supplied.
94
97
  const prInfo = { owner, repo, number: prNumber };
95
- const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
98
+ const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
99
+ host: binding ? binding.host : undefined
100
+ });
96
101
 
97
102
  logger.info(`Stack PR #${prNumber} setup complete (reviewId: ${reviewId}, new: ${isNewReview})`);
98
103
 
@@ -6,6 +6,7 @@ const { PRArgumentParser } = require('./github/parser');
6
6
  const logger = require('./utils/logger');
7
7
  const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
8
8
  const { normalizeRepository } = require('./utils/paths');
9
+ const { isDualHostRepo, hostSetupParamValue } = require('./utils/host-resolution');
9
10
  const { buildInteractiveAnalysisConfig } = require('./interactive-analysis-config');
10
11
  const { version: packageVersion } = require('../package.json');
11
12
 
@@ -169,31 +170,69 @@ function storeAnalysisConfigRemote(port, analysisConfig, _deps) {
169
170
  * the resolved provider/model or council snapshot PLUS custom instructions). When
170
171
  * present it supersedes `councilId` — the id already carries the council selection,
171
172
  * mirroring the cold-start precedence in handlePullRequest/handleLocalReview.
173
+ * @param {string} [context.host] - Setup `?host=` value (alt api_host string or the
174
+ * 'github' sentinel) so a pasted alt URL binds directly instead of re-probing.
175
+ * Already resolved by the caller via `hostSetupParamValue`; omitted when null.
176
+ * @param {string} [context.provider] - CLI provider override to carry to the running server
177
+ * @param {string} [context.model] - CLI model override to carry to the running server
178
+ * @param {string} [context.scope] - Local-mode only: the `--scope` range to carry
179
+ * to the delegated session (e.g. 'branch..untracked').
180
+ * @param {string} [context.base] - Local-mode only: the `--base` branch override.
172
181
  * @returns {string} Full URL
173
182
  */
174
183
  function buildDelegationUrl(port, mode, context = {}) {
175
184
  const base = `http://localhost:${port}`;
185
+
186
+ // The analyze + provider/model override params are built with URLSearchParams
187
+ // so they join and URL-encode safely. The provider/model override rides the
188
+ // URL ONLY alongside analyze=true: the browser auto-analyze path is the sole
189
+ // consumer that reads and then strips them, so carrying them without analyze
190
+ // would leave a stuck ?provider= param that a refresh would replay.
191
+ const extra = new URLSearchParams();
192
+ if (context.analyze) {
193
+ extra.set('analyze', 'true');
194
+ if (context.provider) extra.set('provider', context.provider);
195
+ if (context.model) extra.set('model', context.model);
196
+ }
197
+
198
+ // analysisConfigId supersedes councilId — the stored id already encodes the
199
+ // council selection (plus provider/model + custom instructions), mirroring
200
+ // the cold-start precedence in handlePullRequest/handleLocalReview. These keep
201
+ // encodeURIComponent (percent-encoding, %20 for spaces) rather than the
202
+ // URLSearchParams form-encoding above, to preserve the exact wire format the
203
+ // running server already parses — matching the `path` param, which does the
204
+ // same for the same reason.
205
+ let configParam = '';
206
+ if (context.analysisConfigId) {
207
+ configParam = `analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`;
208
+ } else if (context.councilId) {
209
+ configParam = `council=${encodeURIComponent(context.councilId)}`;
210
+ }
211
+
212
+ // Join the URLSearchParams bundle with the percent-encoded config param.
213
+ const query = [extra.toString(), configParam].filter(Boolean).join('&');
214
+
176
215
  if (mode === 'pr') {
177
216
  let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
178
- const query = [];
179
- if (context.analyze) query.push('analyze=true');
180
- if (context.analysisConfigId) {
181
- query.push(`analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`);
182
- } else if (context.councilId) {
183
- query.push(`council=${encodeURIComponent(context.councilId)}`);
184
- }
185
- if (query.length) url += `?${query.join('&')}`;
217
+ // Thread the pasted URL's host so setup binds it directly (cold-start parity).
218
+ // Host keeps encodeURIComponent (matching analysisConfigId/path) and appends
219
+ // after the shared analyze/provider/model + config bundle, so it rides
220
+ // alongside the provider/model override rather than replacing it.
221
+ const hostParam = context.host ? `host=${encodeURIComponent(context.host)}` : '';
222
+ const prQuery = [query, hostParam].filter(Boolean).join('&');
223
+ if (prQuery) url += `?${prQuery}`;
186
224
  return url;
187
225
  }
188
226
  if (mode === 'local') {
189
- // The `?path=` segment is always present, so analyze/config append with `&`.
227
+ // The `?path=` segment is always present, so the intent bundle appends with `&`.
190
228
  let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
191
- if (context.analyze) url += '&analyze=true';
192
- if (context.analysisConfigId) {
193
- url += `&analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`;
194
- } else if (context.councilId) {
195
- url += `&council=${encodeURIComponent(context.councilId)}`;
196
- }
229
+ if (query) url += `&${query}`;
230
+ // Carry the diff-scope selection so a delegated `--local --scope/--base` run
231
+ // lands at the requested scope instead of the server's default. The receiving
232
+ // setup page relays these and the server re-validates them. These keep
233
+ // encodeURIComponent to match the `path` param's wire format.
234
+ if (context.scope) url += `&scope=${encodeURIComponent(context.scope)}`;
235
+ if (context.base) url += `&base=${encodeURIComponent(context.base)}`;
197
236
  return url;
198
237
  }
199
238
  return `${base}/`;
@@ -281,18 +320,34 @@ async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
281
320
  return storeAnalysisConfigRemote(port, analysisConfig, _deps);
282
321
  };
283
322
 
284
- // Determine mode and build URL
323
+ // Determine mode and build URL. Also carry any --provider/--model override on
324
+ // the URL: under single_port the delegated-to server is a DIFFERENT process
325
+ // whose env never received the flag, so the URL is the only channel that
326
+ // reaches it. buildDelegationUrl only emits provider/model alongside
327
+ // analyze=true (and analysisConfigId supersedes both when present).
285
328
  let url;
286
329
  if (flags.local) {
287
330
  rejectUrlLikeLocalReviewPath(flags.localPath);
288
331
  const targetPath = path.resolve(flags.localPath || process.cwd());
289
332
  const analysisConfigId = await handoffAnalysisConfigId(options.localRepository || null);
290
- url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze, councilId, analysisConfigId });
333
+ url = buildDelegationUrl(port, 'local', {
334
+ localPath: targetPath, analyze, councilId, analysisConfigId,
335
+ provider: flags.provider, model: flags.model,
336
+ scope: flags.scope || null, base: flags.base || null
337
+ });
291
338
  } else if (prArgs.length > 0) {
292
339
  const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
293
340
  const repository = normalizeRepository(prInfo.owner, prInfo.repo);
294
341
  const analysisConfigId = await handoffAnalysisConfigId(repository);
295
- url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze, councilId, analysisConfigId });
342
+ // Thread the parsed host so a pasted alt URL binds directly (no re-probe)
343
+ // same mapping as the cold-start handlePullRequest path. bindingRepository
344
+ // (from a url_pattern match) is the config key that determines dual-ness.
345
+ const bindingRepository = prInfo.bindingRepository || repository;
346
+ const host = hostSetupParamValue(prInfo.host, isDualHostRepo(config, bindingRepository));
347
+ url = buildDelegationUrl(port, 'pr', {
348
+ ...prInfo, analyze, councilId, analysisConfigId, host,
349
+ provider: flags.provider, model: flags.model
350
+ });
296
351
  } else {
297
352
  url = buildDelegationUrl(port, 'server');
298
353
  }