@in-the-loop-labs/pair-review 3.3.7 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.3.7",
3
+ "version": "3.4.0",
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": {
@@ -68,6 +68,7 @@
68
68
  "glob": "^13.0.6",
69
69
  "markdown-it": "^13.0.2",
70
70
  "open": "^9.1.0",
71
+ "semver": "^7.7.4",
71
72
  "simple-git": "^3.19.1",
72
73
  "update-notifier": "^5.1.0",
73
74
  "uuid": "^11.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.3.7",
3
+ "version": "3.4.0",
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": "3.3.7",
3
+ "version": "3.4.0",
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",
@@ -31,7 +31,13 @@
31
31
  --color-warning-bg: #fffae6;
32
32
  --color-warning-border: #ffc552;
33
33
  --color-warning-text: #7d4e00;
34
-
34
+
35
+ --color-info-bg: #eff6ff;
36
+ --color-info-border: #bfdbfe;
37
+ --color-info-accent: #3b82f6;
38
+ --color-info-text: #1e3a8a;
39
+ --color-info-text-muted: #3b5998;
40
+
35
41
  --color-selection: #fff5b1;
36
42
  --color-selection-num: #ffeb3b;
37
43
 
@@ -72,7 +78,13 @@
72
78
  --color-warning-bg: #3d2e00;
73
79
  --color-warning-border: #9e6a03;
74
80
  --color-warning-text: #e3b341;
75
-
81
+
82
+ --color-info-bg: #152033;
83
+ --color-info-border: #2d4a7a;
84
+ --color-info-accent: #4a90e2;
85
+ --color-info-text: #cfe0f5;
86
+ --color-info-text-muted: #8aa9cf;
87
+
76
88
  --color-selection: #3d3300;
77
89
  --color-selection-num: #4d4000;
78
90
 
package/public/index.html CHANGED
@@ -1488,6 +1488,7 @@
1488
1488
 
1489
1489
  <script src="/js/ws-client.js"></script>
1490
1490
  <script src="/js/components/Toast.js"></script>
1491
+ <script src="/js/components/UpdateBanner.js"></script>
1491
1492
  <script src="/js/index.js"></script>
1492
1493
  </body>
1493
1494
  </html>
@@ -0,0 +1,143 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * UpdateBanner Component
4
+ * Shows a persistent, dismissible corner-card notification when a newer
5
+ * version of pair-review is available. Single delivery path: on construction,
6
+ * fetch /api/config and show the banner if a pending_update exists.
7
+ */
8
+
9
+ const DISMISS_KEY = 'update-banner-dismissed';
10
+
11
+ class UpdateBanner {
12
+ constructor() {
13
+ this._banner = null;
14
+ this._dismissBtn = null;
15
+ this._version = null;
16
+
17
+ // Single path: fetch current config at construction; show banner if a
18
+ // pending update exists. No event listener, no WebSocket coupling.
19
+ // `fetch()` returns a Promise; `.then()` chains async steps; the final
20
+ // `.catch()` swallows network errors because the banner is non-critical.
21
+ fetch('/api/config')
22
+ .then(r => (r.ok ? r.json() : null))
23
+ .then(config => {
24
+ if (config && config.pending_update) this.show(config.pending_update);
25
+ })
26
+ .catch(() => { /* non-critical */ });
27
+ }
28
+
29
+ /**
30
+ * Show the update banner for the given version.
31
+ * No-op if already showing or dismissed for this version.
32
+ * @param {string} version
33
+ */
34
+ show(version) {
35
+ if (!version) return;
36
+
37
+ // Already dismissed for this version in this session
38
+ if (sessionStorage.getItem(DISMISS_KEY) === version) return;
39
+
40
+ // Already showing this version
41
+ if (this._banner && this._version === version) return;
42
+
43
+ // Remove any existing banner (e.g., for an older version)
44
+ this._remove();
45
+
46
+ this._version = version;
47
+
48
+ // Theme-aware colors come from CSS custom properties (set in styles.css
49
+ // under :root and [data-theme="dark"]). The inline `var(..., fallback)`
50
+ // form keeps the banner readable even if the stylesheet hasn't loaded
51
+ // yet. No MutationObserver needed — CSS handles the theme switch.
52
+ const banner = document.createElement('div');
53
+ banner.setAttribute('data-update-banner', '');
54
+ Object.assign(banner.style, {
55
+ position: 'fixed',
56
+ top: '16px',
57
+ left: '16px',
58
+ zIndex: '1000',
59
+ maxWidth: '360px',
60
+ background: 'var(--color-info-bg, #eff6ff)',
61
+ border: '1px solid var(--color-info-border, #bfdbfe)',
62
+ borderLeft: '4px solid var(--color-info-accent, #3b82f6)',
63
+ borderRadius: '8px',
64
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
65
+ padding: '12px 14px',
66
+ fontSize: '13px',
67
+ lineHeight: '1.4',
68
+ color: 'var(--color-info-text, #1e3a8a)',
69
+ display: 'flex',
70
+ alignItems: 'flex-start',
71
+ gap: '10px'
72
+ });
73
+
74
+ // Two-line layout: headline + restart instruction on its own line.
75
+ const text = document.createElement('div');
76
+ text.style.flex = '1';
77
+
78
+ const headline = document.createElement('div');
79
+ headline.textContent = `pair-review v${version} is available.`;
80
+
81
+ const instruction = document.createElement('div');
82
+ instruction.textContent = 'Restart the server to update.';
83
+ instruction.style.marginTop = '2px';
84
+
85
+ text.appendChild(headline);
86
+ text.appendChild(instruction);
87
+
88
+ const dismissBtn = document.createElement('button');
89
+ dismissBtn.textContent = '\u00d7';
90
+ dismissBtn.setAttribute('aria-label', 'Dismiss');
91
+ Object.assign(dismissBtn.style, {
92
+ background: 'none',
93
+ border: 'none',
94
+ color: 'var(--color-info-text-muted, #3b5998)',
95
+ cursor: 'pointer',
96
+ fontSize: '18px',
97
+ padding: '0',
98
+ lineHeight: '1',
99
+ flexShrink: '0',
100
+ opacity: '0.8'
101
+ });
102
+ dismissBtn.addEventListener('click', () => this.dismiss());
103
+
104
+ banner.appendChild(text);
105
+ banner.appendChild(dismissBtn);
106
+ document.body.appendChild(banner);
107
+ this._banner = banner;
108
+ this._dismissBtn = dismissBtn;
109
+ }
110
+
111
+ /** Dismiss the banner and remember the choice for this session. */
112
+ dismiss() {
113
+ if (this._version) {
114
+ sessionStorage.setItem(DISMISS_KEY, this._version);
115
+ }
116
+ this._remove();
117
+ }
118
+
119
+ /** @private */
120
+ _remove() {
121
+ if (this._banner && this._banner.parentNode) {
122
+ this._banner.parentNode.removeChild(this._banner);
123
+ }
124
+ this._banner = null;
125
+ this._dismissBtn = null;
126
+ }
127
+ }
128
+
129
+ // Singleton init (browser only)
130
+ if (typeof window !== 'undefined' && !window.updateBanner) {
131
+ if (document.readyState === 'loading') {
132
+ document.addEventListener('DOMContentLoaded', () => {
133
+ window.updateBanner = new UpdateBanner();
134
+ });
135
+ } else {
136
+ window.updateBanner = new UpdateBanner();
137
+ }
138
+ }
139
+
140
+ // CommonJS export for unit tests
141
+ if (typeof module !== 'undefined' && module.exports) {
142
+ module.exports = { UpdateBanner };
143
+ }
@@ -64,6 +64,9 @@ class DiffRenderer {
64
64
  // Ruby
65
65
  'rb': 'ruby',
66
66
  'erb': 'erb',
67
+ // Elixir
68
+ 'ex': 'elixir',
69
+ 'exs': 'elixir',
67
70
  // PHP
68
71
  'php': 'php',
69
72
  // Java/Kotlin/Scala
package/public/local.html CHANGED
@@ -562,6 +562,7 @@
562
562
 
563
563
  <!-- Highlight.js for syntax highlighting -->
564
564
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
565
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
565
566
 
566
567
  <!-- Markdown-it Library for rendering markdown in comments -->
567
568
  <script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
@@ -591,6 +592,7 @@
591
592
  <!-- Components -->
592
593
  <script src="/js/components/TabTitle.js"></script>
593
594
  <script src="/js/components/Toast.js"></script>
595
+ <script src="/js/components/UpdateBanner.js"></script>
594
596
  <script src="/js/components/ConfirmDialog.js"></script>
595
597
  <script src="/js/components/TextInputDialog.js"></script>
596
598
  <script src="/js/components/AnalysisConfigModal.js"></script>
package/public/pr.html CHANGED
@@ -358,6 +358,7 @@
358
358
 
359
359
  <!-- Highlight.js for syntax highlighting -->
360
360
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
361
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/elixir.min.js"></script>
361
362
 
362
363
  <!-- Markdown-it Library for rendering markdown in comments -->
363
364
  <script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.2/dist/markdown-it.min.js"></script>
@@ -387,6 +388,7 @@
387
388
  <!-- Components -->
388
389
  <script src="/js/components/TabTitle.js"></script>
389
390
  <script src="/js/components/Toast.js"></script>
391
+ <script src="/js/components/UpdateBanner.js"></script>
390
392
  <script src="/js/components/ConfirmDialog.js"></script>
391
393
  <script src="/js/components/TextInputDialog.js"></script>
392
394
  <script src="/js/components/AnalysisConfigModal.js"></script>
package/public/setup.html CHANGED
@@ -528,6 +528,7 @@
528
528
 
529
529
  <!-- WebSocket client -->
530
530
  <script src="/js/ws-client.js"></script>
531
+ <script src="/js/components/UpdateBanner.js"></script>
531
532
  <script src="/js/utils/notification-sounds.js"></script>
532
533
 
533
534
  <script>
package/src/config.js CHANGED
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
19
19
  github_token: "",
20
20
  github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
21
21
  port: 7247,
22
+ single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
22
23
  theme: "light",
23
24
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
24
25
  default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
128
129
  try {
129
130
  await fs.access(sourceExample);
130
131
  await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
131
- console.log(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
+ logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
132
133
  return true;
133
134
  } catch (error) {
134
135
  if (error.code === 'ENOENT') {
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
154
155
  if (error.code === 'ENOENT') {
155
156
  try {
156
157
  await fs.mkdir(CONFIG_DIR, { recursive: true });
157
- console.log(`Created config directory: ${CONFIG_DIR}`);
158
+ logger.info(`Created config directory: ${CONFIG_DIR}`);
158
159
  // Copy example config to new directory
159
160
  await copyExampleConfig();
160
161
  return true; // Directory was newly created
161
162
  } catch (mkdirError) {
162
163
  if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
163
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
164
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
164
165
  process.exit(1);
165
166
  }
166
167
  throw mkdirError;
@@ -211,7 +212,7 @@ async function loadConfig() {
211
212
  // Optional files or managed-config-present: skip silently
212
213
  } else if (error instanceof SyntaxError) {
213
214
  if (source.required) {
214
- console.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
+ logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
215
216
  process.exit(1);
216
217
  }
217
218
  logger.warn(`Malformed config at ${source.label}, skipping`);
@@ -235,9 +236,19 @@ async function loadConfig() {
235
236
  mergedConfig.repos = normalized;
236
237
  }
237
238
 
239
+ // PORT env var overrides all config layers (used by Preview and similar harnesses)
240
+ if (process.env.PORT) {
241
+ const envPort = Number(process.env.PORT);
242
+ if (!validatePort(envPort)) {
243
+ logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
244
+ process.exit(1);
245
+ }
246
+ mergedConfig.port = envPort;
247
+ }
248
+
238
249
  // Validate port
239
250
  if (!validatePort(mergedConfig.port)) {
240
- console.error(`Invalid port number ${mergedConfig.port}`);
251
+ logger.error(`Invalid port number ${mergedConfig.port}`);
241
252
  process.exit(1);
242
253
  }
243
254
 
@@ -269,7 +280,7 @@ async function saveConfig(config) {
269
280
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
270
281
  } catch (error) {
271
282
  if (error.code === 'EACCES' || error.code === 'EPERM') {
272
- console.error(`Cannot create configuration directory at ~/.pair-review/`);
283
+ logger.error(`Cannot create configuration directory at ~/.pair-review/`);
273
284
  process.exit(1);
274
285
  }
275
286
  throw error;
package/src/main.js CHANGED
@@ -20,6 +20,7 @@ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/di
20
20
  const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
21
21
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
22
22
  const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
23
+ const { attemptDelegation } = require('./single-port');
23
24
 
24
25
  let db = null;
25
26
 
@@ -432,26 +433,8 @@ AI PROVIDERS:
432
433
  showWelcomeMessage();
433
434
  }
434
435
 
435
- // Initialize database
436
- console.log('Initializing database...');
437
- db = await initializeDatabase(resolveDbName(config));
438
-
439
- // Migrate existing worktrees to database (if any)
440
- const path = require('path');
441
- const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
442
- const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
443
- if (migrationResult.migrated > 0) {
444
- console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
445
- }
446
- if (migrationResult.errors.length > 0) {
447
- console.warn('Some worktrees could not be migrated:', migrationResult.errors);
448
- }
449
-
450
- // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
451
- const poolLifecycle = new WorktreePoolLifecycle(db, config);
452
- await poolLifecycle.resetAndRehydrate();
453
-
454
- // Parse command line arguments including flags
436
+ // Parse command line arguments including flags (before DB init so
437
+ // single-port delegation can skip DB entirely)
455
438
  const { prArgs, flags } = parseArgs(args);
456
439
 
457
440
  // Apply debug_stream from config if not already enabled by CLI flag
@@ -474,6 +457,36 @@ AI PROVIDERS:
474
457
  // server, so we must also apply here.
475
458
  applyConfigOverrides(config);
476
459
 
460
+ // Single-port delegation: if a pair-review server is already running on the
461
+ // configured port, delegate to it (open browser URL) and exit immediately.
462
+ // Skipped for: headless modes (no browser), single_port: false (dev mode).
463
+ if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
464
+ const delegated = await attemptDelegation(config, flags, prArgs);
465
+ if (delegated) {
466
+ process.exit(0);
467
+ }
468
+ // Not delegated — no server running, proceed to start one
469
+ }
470
+
471
+ // Initialize database
472
+ console.log('Initializing database...');
473
+ db = await initializeDatabase(resolveDbName(config));
474
+
475
+ // Migrate existing worktrees to database (if any)
476
+ const path = require('path');
477
+ const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
478
+ const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
479
+ if (migrationResult.migrated > 0) {
480
+ console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
481
+ }
482
+ if (migrationResult.errors.length > 0) {
483
+ console.warn('Some worktrees could not be migrated:', migrationResult.errors);
484
+ }
485
+
486
+ // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
487
+ const poolLifecycle = new WorktreePoolLifecycle(db, config);
488
+ await poolLifecycle.resetAndRehydrate();
489
+
477
490
  // Check for local mode (review uncommitted local changes)
478
491
  if (flags.local) {
479
492
  // Resolve localPath, defaulting to cwd if not provided
package/src/mcp-stdio.js CHANGED
@@ -45,6 +45,13 @@ async function startMCPStdio() {
45
45
  console.error(`[MCP] Warning: failed to load config, using defaults: ${err.message}`);
46
46
  }
47
47
 
48
+ // MCP mode needs its own Express server for stdio↔HTTP bridging and cannot
49
+ // delegate to a running pair-review instance (the stdio transport owns this
50
+ // process). Force auto-port selection to avoid EADDRINUSE when a regular
51
+ // pair-review server is already running on config.port.
52
+ // startServer (src/server.js) reads this env var and flips config.single_port.
53
+ process.env.PAIR_REVIEW_SINGLE_PORT = 'false';
54
+
48
55
  const db = await initializeDatabase(resolveDbName(config));
49
56
  const port = await startServer(db);
50
57
 
@@ -22,11 +22,19 @@ const {
22
22
  const { normalizeRepository } = require('../utils/paths');
23
23
  const { isRunningViaNpx, getGitHubToken } = require('../config');
24
24
  const { version } = require('../../package.json');
25
+ const semver = require('semver');
25
26
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
26
27
  const logger = require('../utils/logger');
27
28
 
28
29
  const router = express.Router();
29
30
 
31
+ // Module-level state: the most recent version we've been told about that's
32
+ // newer than the running server. Plain string, not an object. `null` means
33
+ // nothing is pending. Reset on process restart — which is fine because a
34
+ // restart either IS the update (running version is now newer) or loses no
35
+ // information (the next notifier will re-populate it).
36
+ let pendingUpdateVersion = null;
37
+
30
38
  /**
31
39
  * Get user configuration (for frontend use)
32
40
  * Returns safe-to-expose configuration values
@@ -71,10 +79,46 @@ router.get('/api/config', (req, res) => {
71
79
  icon: config.share.icon || null,
72
80
  label: config.share.label || null,
73
81
  description: config.share.description || null
74
- } : null
82
+ } : null,
83
+ pending_update: pendingUpdateVersion
75
84
  });
76
85
  });
77
86
 
87
+ /**
88
+ * Notify the running server that a newer version is available.
89
+ * Called by a newer CLI invocation delegating to this server.
90
+ * Stores state so browser tabs can pick it up via GET /api/config.
91
+ *
92
+ * Suppression is version-based, not time-based: a POST is accepted only
93
+ * when the incoming version is strictly newer than both the running version
94
+ * and any currently-pending version. This means `pendingUpdateVersion`
95
+ * monotonically increases for the life of the process.
96
+ */
97
+ router.post('/api/notify-update', (req, res) => {
98
+ const incomingVersion = req.body?.version;
99
+ if (!incomingVersion || !semver.valid(incomingVersion)) {
100
+ return res.status(400).json({ error: 'Invalid version' });
101
+ }
102
+
103
+ if (!semver.gt(incomingVersion, version)) {
104
+ return res.json({ ok: true, notified: false, reason: 'not_newer' });
105
+ }
106
+
107
+ // Suppress unless the incoming version is STRICTLY newer than what's
108
+ // already pending. Handles three cases at once:
109
+ // - incoming == pending → suppressed (nothing new)
110
+ // - incoming > pending → accepted (genuinely newer, falls through)
111
+ // - incoming < pending → suppressed (downgrade — user already knows)
112
+ if (pendingUpdateVersion && !semver.gt(incomingVersion, pendingUpdateVersion)) {
113
+ return res.json({ ok: true, notified: false, reason: 'not_newer_than_pending' });
114
+ }
115
+
116
+ pendingUpdateVersion = incomingVersion;
117
+ logger.info(`New version available: ${incomingVersion} (running ${version})`);
118
+
119
+ res.json({ ok: true, notified: true });
120
+ });
121
+
78
122
  /**
79
123
  * Get repository-specific settings
80
124
  * Returns default_instructions, default_provider, and default_model for the repository
@@ -328,4 +372,14 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
328
372
  }
329
373
  });
330
374
 
375
+ /**
376
+ * Test-only helper: reset the in-memory pending-update state.
377
+ * Not exported from index — intended for use by integration tests that
378
+ * share the same module instance and need isolation between cases.
379
+ */
380
+ function _resetPendingUpdate() {
381
+ pendingUpdateVersion = null;
382
+ }
383
+
331
384
  module.exports = router;
385
+ module.exports._resetPendingUpdate = _resetPendingUpdate;
package/src/server.js CHANGED
@@ -14,6 +14,19 @@ let db = null;
14
14
  let server = null;
15
15
  let chatSessionManager = null;
16
16
 
17
+ /**
18
+ * Apply env var overrides to config after loadConfig().
19
+ * Currently handles PAIR_REVIEW_SINGLE_PORT — a bridge for callers that
20
+ * need to force multi-port mode (e.g. mcp-stdio.js). Matches the
21
+ * PAIR_REVIEW_YOLO bridge pattern.
22
+ */
23
+ function applyEnvOverrides(config) {
24
+ if (process.env.PAIR_REVIEW_SINGLE_PORT === 'false') {
25
+ config.single_port = false;
26
+ }
27
+ return config;
28
+ }
29
+
17
30
  /**
18
31
  * Request logging middleware (disabled for cleaner output)
19
32
  */
@@ -93,6 +106,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
93
106
  try {
94
107
  // Load configuration
95
108
  const { config } = await loadConfig();
109
+ applyEnvOverrides(config);
96
110
 
97
111
  // Apply provider configuration overrides (custom models, commands, etc.)
98
112
  applyConfigOverrides(config);
@@ -294,9 +308,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
294
308
  res.sendFile(path.join(__dirname, '..', 'public', 'local.html'));
295
309
  });
296
310
 
297
- // Health check endpoint
311
+ // Health check endpoint (also used by single-port detection)
298
312
  app.get('/health', (req, res) => {
299
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
313
+ res.json({
314
+ status: 'ok',
315
+ service: 'pair-review',
316
+ version: require('../package.json').version,
317
+ timestamp: new Date().toISOString()
318
+ });
300
319
  });
301
320
 
302
321
  // Store database instance, GitHub token, and config for routes
@@ -365,7 +384,9 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
365
384
  });
366
385
 
367
386
  // Find available port and start server
368
- const port = await findAvailablePort(app, config.port);
387
+ const port = config.single_port !== false
388
+ ? config.port // single-port mode: use exact port, fail if unavailable
389
+ : await findAvailablePort(app, config.port);
369
390
 
370
391
  // Check provider availability before accepting requests so /api/config
371
392
  // returns accurate pi_available on the very first request (avoids race
@@ -381,14 +402,35 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
381
402
  console.warn('Provider availability check failed:', err.message);
382
403
  }
383
404
 
384
- server = app.listen(port, () => {
385
- console.log(`Server running on http://localhost:${port}`);
386
- attachWebSocket(server, db, poolLifecycle);
405
+ await new Promise((resolve, reject) => {
406
+ server = app.listen(port, () => {
407
+ console.log(`Server running on http://localhost:${port}`);
408
+ attachWebSocket(server, db, poolLifecycle);
409
+ resolve();
410
+ });
411
+
412
+ // .once instead of .on: this handler detaches after firing exactly once,
413
+ // so the post-startup handler below doesn't double-handle EADDRINUSE/EACCES
414
+ // during the initial bind.
415
+ server.once('error', (error) => {
416
+ if (error.code === 'EADDRINUSE' && config.single_port !== false) {
417
+ reject(new Error(
418
+ `Port ${port} is already in use. A pair-review server may already be running, ` +
419
+ `or another service is using this port. ` +
420
+ `Set "single_port": false in ~/.pair-review/config.json to use automatic port selection.`
421
+ ));
422
+ } else {
423
+ reject(error);
424
+ }
425
+ });
387
426
  });
388
427
 
428
+ // Post-startup error handler. Express middleware handles request-level errors,
429
+ // so this only fires for lower-level issues like accept-loop failures (EMFILE,
430
+ // ENFILE from file descriptor exhaustion). Log but do NOT process.exit — the
431
+ // old code did that and it was too aggressive for transient errors.
389
432
  server.on('error', (error) => {
390
- console.error('Server error:', error);
391
- process.exit(1);
433
+ console.error('Server error after startup:', error);
392
434
  });
393
435
 
394
436
  // Return the actual port the server started on
@@ -468,4 +510,4 @@ if (require.main === module) {
468
510
  startServer();
469
511
  }
470
512
 
471
- module.exports = { startServer };
513
+ module.exports = { startServer, applyEnvOverrides };
@@ -0,0 +1,191 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const http = require('http');
3
+ const path = require('path');
4
+ const semver = require('semver');
5
+ const { PRArgumentParser } = require('./github/parser');
6
+ const logger = require('./utils/logger');
7
+ const { version: packageVersion } = require('../package.json');
8
+
9
+ const HEALTH_TIMEOUT_MS = 2000;
10
+
11
+ // Default dependencies (overridable for testing)
12
+ const defaults = {
13
+ httpGet: http.get,
14
+ httpRequest: http.request,
15
+ logger,
16
+ open: (...args) => process.env.PAIR_REVIEW_NO_OPEN
17
+ ? Promise.resolve()
18
+ : import('open').then(({ default: open }) => open(...args)),
19
+ PRArgumentParser
20
+ };
21
+
22
+ /**
23
+ * Check if a pair-review server is already running on the given port.
24
+ * @param {number} port
25
+ * @param {object} [_deps] - Dependency overrides for testing
26
+ * @returns {Promise<{running: boolean, isPairReview?: boolean, version?: string}>}
27
+ */
28
+ function detectRunningServer(port, _deps) {
29
+ const deps = { ...defaults, ..._deps };
30
+ return new Promise((resolve) => {
31
+ const req = deps.httpGet(`http://localhost:${port}/health`, { timeout: HEALTH_TIMEOUT_MS }, (res) => {
32
+ let data = '';
33
+ res.on('data', (chunk) => { data += chunk; });
34
+ res.on('end', () => {
35
+ try {
36
+ const body = JSON.parse(data);
37
+ if (body.service === 'pair-review') {
38
+ resolve({ running: true, isPairReview: true, version: body.version || null });
39
+ } else {
40
+ resolve({ running: true, isPairReview: false });
41
+ }
42
+ } catch {
43
+ resolve({ running: true, isPairReview: false });
44
+ }
45
+ });
46
+ });
47
+
48
+ req.on('error', () => {
49
+ resolve({ running: false });
50
+ });
51
+
52
+ req.on('timeout', () => {
53
+ req.destroy();
54
+ resolve({ running: false });
55
+ });
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Notify the running server that a newer version is available.
61
+ * Fire-and-forget — does not block on response.
62
+ * @param {number} port
63
+ * @param {string} currentVersion - Version of the current CLI invocation
64
+ * @param {object} [_deps] - Dependency overrides for testing
65
+ */
66
+ function notifyVersion(port, currentVersion, _deps) {
67
+ const deps = { ...defaults, ..._deps };
68
+ const payload = JSON.stringify({ version: currentVersion });
69
+ const req = deps.httpRequest({
70
+ hostname: 'localhost',
71
+ port,
72
+ path: '/api/notify-update',
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ 'Content-Length': Buffer.byteLength(payload)
77
+ },
78
+ timeout: HEALTH_TIMEOUT_MS
79
+ }, () => { /* ignore response */ });
80
+
81
+ req.on('error', () => { /* fire and forget */ });
82
+ req.on('timeout', () => { req.destroy(); });
83
+ req.write(payload);
84
+ req.end();
85
+ }
86
+
87
+ /**
88
+ * Build the URL to delegate to an existing server.
89
+ * @param {number} port
90
+ * @param {'pr'|'local'|'server'} mode
91
+ * @param {object} context
92
+ * @param {string} [context.owner]
93
+ * @param {string} [context.repo]
94
+ * @param {number} [context.number]
95
+ * @param {string} [context.localPath]
96
+ * @param {boolean} [context.analyze] - Whether to trigger auto-analysis
97
+ * @returns {string} Full URL
98
+ */
99
+ function buildDelegationUrl(port, mode, context = {}) {
100
+ const base = `http://localhost:${port}`;
101
+ if (mode === 'pr') {
102
+ let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
103
+ if (context.analyze) url += '?analyze=true';
104
+ return url;
105
+ }
106
+ if (mode === 'local') {
107
+ let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
108
+ if (context.analyze) url += '&analyze=true';
109
+ return url;
110
+ }
111
+ return `${base}/`;
112
+ }
113
+
114
+ /**
115
+ * Parse PR arguments for URL construction without starting a server.
116
+ * Reuses PRArgumentParser — synchronous for URLs, async for bare numbers.
117
+ * @param {string[]} prArgs - Raw CLI PR arguments
118
+ * @param {object} [_deps] - Dependency overrides for testing
119
+ * @returns {Promise<{owner: string, repo: string, number: number}>}
120
+ */
121
+ async function parsePRArgsForDelegation(prArgs, _deps) {
122
+ const deps = { ...defaults, ..._deps };
123
+ const parser = new deps.PRArgumentParser();
124
+ return parser.parsePRArguments(prArgs);
125
+ }
126
+
127
+ /**
128
+ * Attempt single-port delegation. Returns true if delegation happened (caller should exit).
129
+ * Returns false if no running server was found (caller should start normally).
130
+ * Throws if port is occupied by a non-pair-review service.
131
+ *
132
+ * @param {object} config - Loaded config
133
+ * @param {object} flags - Parsed CLI flags
134
+ * @param {string[]} prArgs - PR arguments from CLI
135
+ * @param {object} [_deps] - Dependency overrides for testing
136
+ * @returns {Promise<boolean>} true if delegated, false if should start fresh
137
+ */
138
+ async function attemptDelegation(config, flags, prArgs, _deps) {
139
+ const deps = { ...defaults, ..._deps };
140
+ const port = config.port;
141
+
142
+ const result = await detectRunningServer(port, _deps);
143
+
144
+ if (result.running && !result.isPairReview) {
145
+ throw new Error(
146
+ `Port ${port} is in use by another service. ` +
147
+ `Either stop that service, or set a different port in ~/.pair-review/config.json`
148
+ );
149
+ }
150
+
151
+ if (!result.running) {
152
+ return false;
153
+ }
154
+
155
+ // Server is running — delegate to it
156
+ deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
157
+
158
+ // Determine mode and build URL
159
+ let url;
160
+ if (flags.local) {
161
+ const targetPath = path.resolve(flags.localPath || process.cwd());
162
+ url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
163
+ } else if (prArgs.length > 0) {
164
+ const prInfo = await parsePRArgsForDelegation(prArgs, _deps);
165
+ url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
166
+ } else {
167
+ url = buildDelegationUrl(port, 'server');
168
+ }
169
+
170
+ // Notify running server of newer version if applicable
171
+ if (result.version && semver.valid(packageVersion) && semver.valid(result.version)) {
172
+ if (semver.gt(packageVersion, result.version)) {
173
+ deps.logger.info(`Notifying server of newer version: ${packageVersion} > ${result.version}`);
174
+ notifyVersion(port, packageVersion, _deps);
175
+ }
176
+ }
177
+
178
+ // Open browser and exit
179
+ deps.logger.info(`Delegating to running server: ${url}`);
180
+ await deps.open(url);
181
+ return true;
182
+ }
183
+
184
+ module.exports = {
185
+ detectRunningServer,
186
+ notifyVersion,
187
+ buildDelegationUrl,
188
+ parsePRArgsForDelegation,
189
+ attemptDelegation,
190
+ HEALTH_TIMEOUT_MS
191
+ };