@in-the-loop-labs/pair-review 2.4.3 → 2.5.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": "2.4.3",
3
+ "version": "2.5.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "2.4.3",
3
+ "version": "2.5.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": "2.4.3",
3
+ "version": "2.5.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",
package/public/css/pr.css CHANGED
@@ -1188,8 +1188,7 @@
1188
1188
  background: var(--color-bg-primary);
1189
1189
  border: 1px solid var(--color-border-primary);
1190
1190
  border-radius: 8px;
1191
- overflow-x: auto;
1192
- overflow-y: visible;
1191
+ overflow: visible; /* Must be visible so sticky file headers work relative to .diff-view */
1193
1192
  position: relative;
1194
1193
  min-width: 0;
1195
1194
  padding-bottom: 1px;
@@ -1335,7 +1334,7 @@
1335
1334
  /* Diff styling improvements */
1336
1335
  .d2h-file-wrapper[data-file-name] {
1337
1336
  margin-bottom: 24px;
1338
- overflow-x: auto; /* Allow horizontal scroll for long code lines */
1337
+ overflow-x: visible; /* Must be visible for sticky file headers to work */
1339
1338
  max-width: 100%;
1340
1339
  }
1341
1340
 
@@ -1348,6 +1347,9 @@
1348
1347
  border-bottom: 1px solid var(--color-border-primary);
1349
1348
  font-weight: 600;
1350
1349
  font-size: 14px;
1350
+ position: sticky;
1351
+ top: var(--toolbar-height, 0px);
1352
+ z-index: 4;
1351
1353
  }
1352
1354
 
1353
1355
  .d2h-file-name {
@@ -1540,7 +1542,8 @@
1540
1542
  color: #f85149;
1541
1543
  }
1542
1544
 
1543
- /* Hide diff table when collapsed */
1545
+ /* Hide diff content when collapsed */
1546
+ .d2h-file-wrapper.collapsed .d2h-file-body,
1544
1547
  .d2h-file-wrapper.collapsed .d2h-diff-table {
1545
1548
  display: none;
1546
1549
  }
@@ -1560,6 +1563,12 @@
1560
1563
  cursor: pointer;
1561
1564
  }
1562
1565
 
1566
+ /* Scrollable wrapper for diff tables — provides per-file horizontal scroll
1567
+ now that .d2h-file-wrapper and .diff-container use overflow:visible for sticky headers */
1568
+ .d2h-file-body {
1569
+ overflow-x: auto;
1570
+ }
1571
+
1563
1572
  .d2h-diff-table {
1564
1573
  width: 100%;
1565
1574
  border-collapse: collapse;
@@ -6288,6 +6297,9 @@ body:not([data-theme="dark"]) .theme-icon-light {
6288
6297
  background: var(--color-bg-primary);
6289
6298
  border-bottom: 1px solid var(--color-border-primary);
6290
6299
  flex-shrink: 0;
6300
+ position: sticky;
6301
+ top: 0;
6302
+ z-index: 5;
6291
6303
  }
6292
6304
 
6293
6305
  .diff-toolbar .sidebar-toggle-collapsed {
@@ -6759,11 +6771,10 @@ body:not([data-theme="dark"]) .theme-icon-light {
6759
6771
  }
6760
6772
 
6761
6773
  .main-layout .diff-container {
6762
- flex: 1;
6774
+ flex: 1 0 auto; /* Grow to fill, but don't shrink below content so .diff-view scrolls */
6763
6775
  min-width: 0; /* Prevent flex item from expanding beyond container */
6764
6776
  padding: 16px;
6765
- overflow-y: auto;
6766
- overflow-x: auto; /* Allow scroll as fallback for unbreakable content */
6777
+ overflow: visible; /* Must be visible so sticky file headers work relative to .diff-view */
6767
6778
  }
6768
6779
 
6769
6780
  /* --------------------------------------------------------------------------
@@ -11055,6 +11066,39 @@ body.resizing * {
11055
11066
  background: linear-gradient(to bottom, var(--chat-subtle), transparent);
11056
11067
  flex-shrink: 0;
11057
11068
  }
11069
+
11070
+ /* Status flash pill — transient notification between header and content */
11071
+ .chat-panel__status-flash {
11072
+ display: flex;
11073
+ justify-content: center;
11074
+ padding: 6px 16px;
11075
+ flex-shrink: 0;
11076
+ opacity: 0;
11077
+ transition: opacity 0.3s ease;
11078
+ }
11079
+
11080
+ .chat-panel__status-flash--visible {
11081
+ opacity: 1;
11082
+ }
11083
+
11084
+ .chat-panel__status-flash-text {
11085
+ display: inline-block;
11086
+ padding: 3px 12px;
11087
+ font-size: 11px;
11088
+ font-weight: 600;
11089
+ letter-spacing: 0.02em;
11090
+ color: #92400e;
11091
+ background: #fef3c7;
11092
+ border: 1px solid #fcd34d;
11093
+ border-radius: 999px;
11094
+ }
11095
+
11096
+ [data-theme="dark"] .chat-panel__status-flash-text {
11097
+ color: #fbbf24;
11098
+ background: rgba(251, 191, 36, 0.12);
11099
+ border-color: rgba(251, 191, 36, 0.3);
11100
+ }
11101
+
11058
11102
  /* Provider picker */
11059
11103
  .chat-panel__provider-picker {
11060
11104
  position: relative;
@@ -32,6 +32,7 @@ class ChatPanel {
32
32
  this._analysisContextRemoved = false;
33
33
  this._sessionAnalysisRunId = null; // tracks which AI run ID's context is loaded in the current session
34
34
  this._openPromise = null; // concurrency guard for open()
35
+ this._sessionWarm = false; // true once the session has been used in this page load
35
36
  this._activeProvider = window.__pairReview?.chatProvider || 'pi';
36
37
  this._chatProviders = window.__pairReview?.chatProviders || [];
37
38
 
@@ -84,6 +85,9 @@ class ChatPanel {
84
85
  </button>
85
86
  </div>
86
87
  </div>
88
+ <div class="chat-panel__status-flash" style="display:none">
89
+ <span class="chat-panel__status-flash-text">Starting Agent Client Protocol</span>
90
+ </div>
87
91
  <div class="chat-panel__messages-wrapper">
88
92
  <div class="chat-panel__messages" id="chat-messages">
89
93
  <div class="chat-panel__empty">
@@ -170,6 +174,7 @@ class ChatPanel {
170
174
  this.historyBtn = this.container.querySelector('.chat-panel__history-btn');
171
175
  this.titleTextEl = this.container.querySelector('.chat-panel__title-text');
172
176
  this.newContentPill = this.container.querySelector('.chat-panel__new-content-pill');
177
+ this.statusFlash = this.container.querySelector('.chat-panel__status-flash');
173
178
  }
174
179
 
175
180
  /**
@@ -407,6 +412,53 @@ class ChatPanel {
407
412
  return providerId.charAt(0).toUpperCase() + providerId.slice(1);
408
413
  }
409
414
 
415
+ /**
416
+ * Check if the active provider uses ACP (Agent Client Protocol).
417
+ * @returns {boolean}
418
+ */
419
+ _isAcpProvider() {
420
+ const entry = this._chatProviders.find(p => p.id === this._activeProvider);
421
+ return entry?.type === 'acp';
422
+ }
423
+
424
+ /**
425
+ * Show a transient status flash pill (e.g. "Starting Agent Client Protocol").
426
+ * Auto-hides after the given timeout.
427
+ * @param {string} text - Text to display
428
+ * @param {number} [timeout=5000] - Max display time in ms
429
+ */
430
+ _showStatusFlash(text, timeout = 5000) {
431
+ if (!this.statusFlash) return;
432
+ if (this._hideAnimationTimeout) {
433
+ clearTimeout(this._hideAnimationTimeout);
434
+ this._hideAnimationTimeout = null;
435
+ }
436
+ const textEl = this.statusFlash.querySelector('.chat-panel__status-flash-text');
437
+ if (textEl) textEl.textContent = text;
438
+ this.statusFlash.style.display = '';
439
+ // Force reflow to ensure the fade-in animation triggers
440
+ void this.statusFlash.offsetHeight;
441
+ this.statusFlash.classList.add('chat-panel__status-flash--visible');
442
+ this._statusFlashTimeout = setTimeout(() => this._hideStatusFlash(), timeout);
443
+ }
444
+
445
+ /**
446
+ * Hide the status flash pill with a fade-out animation.
447
+ */
448
+ _hideStatusFlash() {
449
+ if (this._statusFlashTimeout) {
450
+ clearTimeout(this._statusFlashTimeout);
451
+ this._statusFlashTimeout = null;
452
+ }
453
+ if (!this.statusFlash) return;
454
+ this.statusFlash.classList.remove('chat-panel__status-flash--visible');
455
+ // Hide after transition completes
456
+ this._hideAnimationTimeout = setTimeout(() => {
457
+ if (this.statusFlash) this.statusFlash.style.display = 'none';
458
+ this._hideAnimationTimeout = null;
459
+ }, 300);
460
+ }
461
+
410
462
  /**
411
463
  * Open the chat panel
412
464
  * @param {Object} options - Optional context
@@ -666,6 +718,7 @@ class ChatPanel {
666
718
 
667
719
  const mru = sessions[0];
668
720
  this.currentSessionId = mru.id;
721
+ this._sessionWarm = false;
669
722
  this._resubscribeChat();
670
723
  console.debug('[ChatPanel] Loaded MRU session:', mru.id, 'messages:', mru.message_count);
671
724
 
@@ -944,6 +997,7 @@ class ChatPanel {
944
997
 
945
998
  // 2. Reset state
946
999
  this.currentSessionId = sessionId;
1000
+ this._sessionWarm = false;
947
1001
  this._resubscribeChat();
948
1002
  this.messages = [];
949
1003
  this._streamingContent = '';
@@ -1081,6 +1135,11 @@ class ChatPanel {
1081
1135
  return null;
1082
1136
  }
1083
1137
 
1138
+ const isAcp = this._isAcpProvider();
1139
+ if (isAcp) {
1140
+ this._showStatusFlash('Starting Agent Client Protocol');
1141
+ }
1142
+
1084
1143
  try {
1085
1144
  const body = {
1086
1145
  provider: this._activeProvider,
@@ -1100,6 +1159,8 @@ class ChatPanel {
1100
1159
  body: JSON.stringify(body)
1101
1160
  });
1102
1161
 
1162
+ if (isAcp) this._hideStatusFlash();
1163
+
1103
1164
  if (!response.ok) {
1104
1165
  const err = await response.json().catch(() => ({}));
1105
1166
  throw new Error(err.error || 'Failed to create chat session');
@@ -1107,10 +1168,12 @@ class ChatPanel {
1107
1168
 
1108
1169
  const result = await response.json();
1109
1170
  this.currentSessionId = result.data.id;
1171
+ this._sessionWarm = true;
1110
1172
  this._resubscribeChat();
1111
1173
  console.debug('[ChatPanel] Session created:', this.currentSessionId);
1112
1174
  return result.data;
1113
1175
  } catch (error) {
1176
+ if (isAcp) this._hideStatusFlash();
1114
1177
  console.error('[ChatPanel] Error creating session:', error);
1115
1178
  this._showError('Failed to start chat session. ' + error.message);
1116
1179
  return null;
@@ -1197,6 +1260,12 @@ class ChatPanel {
1197
1260
  this._pendingActionContext = null;
1198
1261
  }
1199
1262
 
1263
+ // Show ACP resume flash when the session may need server-side auto-resume
1264
+ const acpResuming = this._isAcpProvider() && !this._sessionWarm;
1265
+ if (acpResuming) {
1266
+ this._showStatusFlash('Resuming Agent Client Protocol');
1267
+ }
1268
+
1200
1269
  // Send to API
1201
1270
  try {
1202
1271
  console.debug('[ChatPanel] Sending message to session', this.currentSessionId);
@@ -1206,7 +1275,15 @@ class ChatPanel {
1206
1275
  body: JSON.stringify(payload)
1207
1276
  });
1208
1277
 
1209
- // Handle 410 Gone: session is not resumable — transparently create a new one and retry once
1278
+ if (acpResuming) {
1279
+ this._hideStatusFlash();
1280
+ this._sessionWarm = true;
1281
+ }
1282
+
1283
+ // Handle 410 Gone: session is not resumable — transparently create a new one and retry once.
1284
+ // Note: we do NOT call _hideStatusFlash() here. createSession() will call
1285
+ // _showStatusFlash() which overwrites the pill text directly, avoiding a
1286
+ // visible hide/show flicker during the transparent retry.
1210
1287
  if (response.status === 410) {
1211
1288
  console.debug('[ChatPanel] Session not resumable (410), creating new session and retrying');
1212
1289
  this.currentSessionId = null;
@@ -1230,6 +1307,7 @@ class ChatPanel {
1230
1307
  }
1231
1308
  console.debug('[ChatPanel] Message accepted, waiting for WebSocket events');
1232
1309
  } catch (error) {
1310
+ if (acpResuming) this._hideStatusFlash();
1233
1311
  // Restore pending context so it's not lost
1234
1312
  this._pendingContext = savedContext;
1235
1313
  this._pendingContextData = savedContextData;
@@ -402,11 +402,6 @@
402
402
  state.fetchedAt = data.fetched_at;
403
403
 
404
404
  renderCollectionTable(container, state, collection);
405
-
406
- // Auto-refresh on first load if cache is empty
407
- if (state.prs.length === 0 && !state.fetchedAt) {
408
- refreshCollectionPrs(collection, containerId, state);
409
- }
410
405
  } catch (error) {
411
406
  console.error('Error loading ' + collection + ':', error);
412
407
  container.innerHTML =
@@ -1164,11 +1159,11 @@
1164
1159
  if (collectionRow && !event.target.closest('a')) {
1165
1160
  var prUrl = collectionRow.dataset.prUrl;
1166
1161
  if (prUrl) {
1167
- // Switch to PR tab to show loading state
1162
+ // Switch to PR tab to show loading state (do NOT persist to
1163
+ // localStorage – the user's intentional tab choice should be preserved)
1168
1164
  var tabBar = document.getElementById('unified-tab-bar');
1169
1165
  var prTabBtn = tabBar.querySelector('[data-tab="pr-tab"]');
1170
1166
  switchTab(tabBar, prTabBtn);
1171
- localStorage.setItem(TAB_STORAGE_KEY, 'pr-tab');
1172
1167
 
1173
1168
  // Populate input and submit the form programmatically
1174
1169
  var input = document.getElementById('pr-url-input');
@@ -1204,19 +1199,25 @@
1204
1199
  const unifiedTabBtn = event.target.closest('#unified-tab-bar .tab-btn');
1205
1200
  if (unifiedTabBtn) {
1206
1201
  const tabBar = document.getElementById('unified-tab-bar');
1207
- switchTab(tabBar, unifiedTabBtn, function (tabId) {
1202
+ switchTab(tabBar, unifiedTabBtn, async function (tabId) {
1208
1203
  // Persist tab choice
1209
1204
  localStorage.setItem(TAB_STORAGE_KEY, tabId);
1210
1205
  // Lazy-load local reviews on first switch
1211
1206
  if (tabId === 'local-tab' && !localReviewsPagination.loaded) {
1212
1207
  loadLocalReviews();
1213
1208
  }
1214
- // Lazy-load GitHub collection tabs on first switch
1215
- if (tabId === 'review-requests-tab' && !reviewRequestsState.loaded) {
1216
- loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1209
+ // Load cached data on first switch, then always refresh from GitHub
1210
+ if (tabId === 'review-requests-tab') {
1211
+ if (!reviewRequestsState.loaded) {
1212
+ await loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1213
+ }
1214
+ refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1217
1215
  }
1218
- if (tabId === 'my-prs-tab' && !myPrsState.loaded) {
1219
- loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1216
+ if (tabId === 'my-prs-tab') {
1217
+ if (!myPrsState.loaded) {
1218
+ await loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1219
+ }
1220
+ refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1220
1221
  }
1221
1222
  });
1222
1223
  return;
@@ -1248,12 +1249,14 @@
1248
1249
  loadLocalReviews();
1249
1250
  }
1250
1251
 
1251
- // If a GitHub collection tab is active, load it immediately
1252
+ // If a GitHub collection tab is active, load cached data then refresh from GitHub
1252
1253
  if (savedTab === 'review-requests-tab') {
1253
- loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState);
1254
+ loadCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState)
1255
+ .then(function () { refreshCollectionPrs('review-requests', 'review-requests-container', reviewRequestsState); });
1254
1256
  }
1255
1257
  if (savedTab === 'my-prs-tab') {
1256
- loadCollectionPrs('my-prs', 'my-prs-container', myPrsState);
1258
+ loadCollectionPrs('my-prs', 'my-prs-container', myPrsState)
1259
+ .then(function () { refreshCollectionPrs('my-prs', 'my-prs-container', myPrsState); });
1257
1260
  }
1258
1261
 
1259
1262
  // Set up start review form handler
package/public/js/pr.js CHANGED
@@ -180,6 +180,9 @@ class PRManager {
180
180
  this.initAnalysisConfigModal();
181
181
  this.initKeyboardShortcuts();
182
182
 
183
+ // Track toolbar height for sticky file headers (they sit below the sticky toolbar)
184
+ this._initToolbarHeightTracking();
185
+
183
186
  // Initialize diff options dropdown (gear icon for whitespace toggle).
184
187
  // Must happen before init() so the persisted hideWhitespace state is
185
188
  // applied before the first loadAndDisplayFiles() call.
@@ -238,6 +241,27 @@ class PRManager {
238
241
  };
239
242
  }
240
243
 
244
+ /**
245
+ * Keep --toolbar-height CSS variable in sync with the actual toolbar size
246
+ * so sticky file headers can position themselves below the sticky toolbar.
247
+ */
248
+ _initToolbarHeightTracking() {
249
+ const toolbar = document.querySelector('.diff-toolbar');
250
+ if (!toolbar) return;
251
+
252
+ const update = () => {
253
+ document.documentElement.style.setProperty(
254
+ '--toolbar-height', toolbar.offsetHeight + 'px'
255
+ );
256
+ };
257
+ update();
258
+
259
+ // Re-measure when toolbar resizes (e.g. analysis dots appear/disappear)
260
+ if (typeof ResizeObserver !== 'undefined') {
261
+ new ResizeObserver(update).observe(toolbar);
262
+ }
263
+ }
264
+
241
265
  /**
242
266
  * Set up event handlers
243
267
  */
@@ -1182,7 +1206,13 @@ class PRManager {
1182
1206
  }
1183
1207
 
1184
1208
  table.appendChild(tbody);
1185
- wrapper.appendChild(table);
1209
+
1210
+ // Wrap table in a scrollable container for horizontal scroll of long code lines
1211
+ // (parent elements use overflow:visible to support sticky file headers)
1212
+ const fileBody = document.createElement('div');
1213
+ fileBody.className = 'd2h-file-body';
1214
+ fileBody.appendChild(table);
1215
+ wrapper.appendChild(fileBody);
1186
1216
 
1187
1217
  return wrapper;
1188
1218
  }
@@ -4784,7 +4814,11 @@ class PRManager {
4784
4814
  table.className = 'd2h-diff-table';
4785
4815
  const tbody = this._buildContextChunkTbody(data, contextFile);
4786
4816
  table.appendChild(tbody);
4787
- wrapper.appendChild(table);
4817
+
4818
+ const fileBody = document.createElement('div');
4819
+ fileBody.className = 'd2h-file-body';
4820
+ fileBody.appendChild(table);
4821
+ wrapper.appendChild(fileBody);
4788
4822
 
4789
4823
  // Insert in sorted path order among existing file wrappers
4790
4824
  const allWrappers = [...diffContainer.querySelectorAll('.d2h-file-wrapper')];
@@ -96,17 +96,40 @@ function applyConfigOverrides(providersConfig) {
96
96
 
97
97
  /**
98
98
  * Get a chat provider definition with config overrides merged.
99
- * @param {string} id - Provider ID (e.g. 'copilot-acp')
99
+ * Supports both built-in providers and dynamic providers defined entirely in config.
100
+ * @param {string} id - Provider ID (e.g. 'copilot-acp', or a custom ID like 'river')
100
101
  * @returns {Object|null} Provider definition or null if unknown
101
102
  */
102
103
  function getChatProvider(id) {
103
104
  const base = CHAT_PROVIDERS[id];
104
- if (!base) return null;
105
-
106
105
  const overrides = _configOverrides[id];
106
+
107
+ if (!base && !overrides) return null;
108
+
109
+ // Dynamic provider defined entirely in config
110
+ if (!base) {
111
+ const provider = {
112
+ id,
113
+ name: overrides.name || overrides.label || id,
114
+ type: overrides.type || 'acp',
115
+ command: overrides.command || id,
116
+ args: overrides.args || [],
117
+ env: overrides.env || {},
118
+ };
119
+ if (overrides.model) provider.model = overrides.model;
120
+ if (overrides.extra_args && Array.isArray(overrides.extra_args)) {
121
+ provider.args = [...provider.args, ...overrides.extra_args];
122
+ }
123
+ if (provider.command.includes(' ')) {
124
+ provider.useShell = true;
125
+ }
126
+ return provider;
127
+ }
128
+
107
129
  if (!overrides) return { ...base };
108
130
 
109
131
  const merged = { ...base };
132
+ if (overrides.name || overrides.label) merged.name = overrides.name || overrides.label;
110
133
  if (overrides.command) merged.command = overrides.command;
111
134
  if (overrides.model) merged.model = overrides.model;
112
135
  if (overrides.env) merged.env = { ...merged.env, ...overrides.env };
@@ -125,11 +148,15 @@ function getChatProvider(id) {
125
148
  }
126
149
 
127
150
  /**
128
- * Get all chat provider definitions (with overrides applied).
151
+ * Get all chat provider definitions (built-in + dynamic from config).
129
152
  * @returns {Array<Object>}
130
153
  */
131
154
  function getAllChatProviders() {
132
- return Object.keys(CHAT_PROVIDERS).map(id => getChatProvider(id));
155
+ const ids = new Set([
156
+ ...Object.keys(CHAT_PROVIDERS),
157
+ ...Object.keys(_configOverrides),
158
+ ]);
159
+ return [...ids].map(id => getChatProvider(id)).filter(Boolean);
133
160
  }
134
161
 
135
162
  /**
@@ -138,7 +165,7 @@ function getAllChatProviders() {
138
165
  * @returns {boolean}
139
166
  */
140
167
  function isAcpProvider(id) {
141
- const provider = CHAT_PROVIDERS[id];
168
+ const provider = getChatProvider(id);
142
169
  return provider?.type === 'acp';
143
170
  }
144
171
 
@@ -148,7 +175,7 @@ function isAcpProvider(id) {
148
175
  * @returns {boolean}
149
176
  */
150
177
  function isClaudeCodeProvider(id) {
151
- const provider = CHAT_PROVIDERS[id];
178
+ const provider = getChatProvider(id);
152
179
  return provider?.type === 'claude';
153
180
  }
154
181
 
@@ -158,7 +185,7 @@ function isClaudeCodeProvider(id) {
158
185
  * @returns {boolean}
159
186
  */
160
187
  function isCodexProvider(id) {
161
- const provider = CHAT_PROVIDERS[id];
188
+ const provider = getChatProvider(id);
162
189
  return provider?.type === 'codex';
163
190
  }
164
191
 
@@ -223,7 +250,7 @@ async function checkChatProviderAvailability(id, _deps) {
223
250
  * @returns {Promise<void>}
224
251
  */
225
252
  async function checkAllChatProviders(_deps) {
226
- const ids = Object.keys(CHAT_PROVIDERS);
253
+ const ids = [...new Set([...Object.keys(CHAT_PROVIDERS), ...Object.keys(_configOverrides)])];
227
254
  const results = await Promise.all(
228
255
  ids.map(async (id) => {
229
256
  const result = await checkChatProviderAvailability(id, _deps);
package/src/config.js CHANGED
@@ -2,8 +2,11 @@
2
2
  const fs = require('fs').promises;
3
3
  const path = require('path');
4
4
  const os = require('os');
5
+ const childProcess = require('child_process');
5
6
  const logger = require('./utils/logger');
6
7
 
8
+ let _cachedCommandToken = null;
9
+
7
10
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
8
11
  const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
9
12
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -13,6 +16,7 @@ const PACKAGE_ROOT = path.join(__dirname, '..');
13
16
 
14
17
  const DEFAULT_CONFIG = {
15
18
  github_token: "",
19
+ github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
16
20
  port: 7247,
17
21
  theme: "light",
18
22
  default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
@@ -247,17 +251,54 @@ function getConfigDir() {
247
251
  * Priority:
248
252
  * 1. GITHUB_TOKEN environment variable (highest priority)
249
253
  * 2. config.github_token from ~/.pair-review/config.json
254
+ * 3. config.github_token_command — execute shell command, use stdout (cached on success)
255
+ * 4. Empty string (no token)
250
256
  *
251
257
  * @param {Object} config - Configuration object from loadConfig()
252
258
  * @returns {string} - GitHub token or empty string if not configured
253
259
  */
254
260
  function getGitHubToken(config) {
255
- // Environment variable takes precedence
256
261
  if (process.env.GITHUB_TOKEN) {
262
+ logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
257
263
  return process.env.GITHUB_TOKEN;
258
264
  }
259
- // Fall back to config file
260
- return config.github_token || '';
265
+ if (config.github_token) {
266
+ logger.debug('Using GitHub token from config.github_token');
267
+ return config.github_token;
268
+ }
269
+ if (config.github_token_command) {
270
+ if (_cachedCommandToken !== null) {
271
+ logger.debug('Using GitHub token from github_token_command (cached)');
272
+ return _cachedCommandToken;
273
+ }
274
+ logger.debug(`Attempting GitHub token from command: ${config.github_token_command}`);
275
+ try {
276
+ const result = childProcess.execSync(config.github_token_command, {
277
+ encoding: 'utf8',
278
+ timeout: 5000,
279
+ stdio: ['pipe', 'pipe', 'ignore']
280
+ }).trim();
281
+ if (!result) {
282
+ logger.warn(`github_token_command did not produce a token (command: ${config.github_token_command})`);
283
+ return '';
284
+ }
285
+ logger.debug('Using GitHub token from github_token_command');
286
+ _cachedCommandToken = result;
287
+ return result;
288
+ } catch (error) {
289
+ logger.warn(`github_token_command failed (command: ${config.github_token_command}): ${error.message}`);
290
+ return '';
291
+ }
292
+ }
293
+ logger.debug('No GitHub token configured');
294
+ return '';
295
+ }
296
+
297
+ /**
298
+ * Resets the cached command token. Exported for testing only.
299
+ */
300
+ function _resetTokenCache() {
301
+ _cachedCommandToken = null;
261
302
  }
262
303
 
263
304
  /**
@@ -465,5 +506,6 @@ module.exports = {
465
506
  resolveMonorepoOptions,
466
507
  resolveDbName,
467
508
  warnIfDevModeWithoutDbName,
509
+ _resetTokenCache,
468
510
  DEFAULT_CHECKOUT_TIMEOUT_MS
469
511
  };
package/src/main.js CHANGED
@@ -318,7 +318,7 @@ CONFIG FILE:
318
318
 
319
319
  Example config:
320
320
  {
321
- "github_token": "ghp_your_token_here",
321
+ "github_token_command": "gh auth token",
322
322
  "port": 7247,
323
323
  "theme": "light",
324
324
  "debug_stream": false,
@@ -327,7 +327,10 @@ CONFIG FILE:
327
327
  }
328
328
 
329
329
  GITHUB TOKEN:
330
- Create a Personal Access Token at:
330
+ If you have the GitHub CLI (gh) installed and authenticated,
331
+ you're all set — the default github_token_command handles it.
332
+
333
+ Otherwise, create a Personal Access Token at:
331
334
  https://github.com/settings/tokens/new
332
335
 
333
336
  Required scopes:
@@ -336,7 +339,9 @@ GITHUB TOKEN:
336
339
 
337
340
  You can provide the token via:
338
341
  1. GITHUB_TOKEN environment variable (takes precedence)
339
- 2. github_token field in config file
342
+ 2. github_token field in config file (**deprecated**)
343
+ 3. github_token_command in config file (**preferred** for security, default: "gh auth token")
344
+ No secret stored in plain text. Works with gh CLI, 1Password CLI, pass, etc.
340
345
 
341
346
  ENVIRONMENT VARIABLES:
342
347
  GITHUB_TOKEN GitHub Personal Access Token (takes precedence over config file)
@@ -504,7 +509,7 @@ async function handlePullRequest(args, config, db, flags = {}) {
504
509
  // Get GitHub token (env var takes precedence over config)
505
510
  const githubToken = getGitHubToken(config);
506
511
  if (!githubToken) {
507
- throw new Error('GitHub token not found. Set GITHUB_TOKEN environment variable or run: npx pair-review --configure');
512
+ throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
508
513
  }
509
514
 
510
515
  // Parse PR arguments
@@ -600,7 +605,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
600
605
  // Get GitHub token (env var takes precedence over config)
601
606
  const githubToken = getGitHubToken(config);
602
607
  if (!githubToken) {
603
- throw new Error('GitHub token not found. Set GITHUB_TOKEN environment variable or run: npx pair-review --configure');
608
+ throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
604
609
  }
605
610
 
606
611
  // Parse PR arguments
@@ -37,7 +37,7 @@ router.get('/api/config', (req, res) => {
37
37
  // Build chat_providers array with availability
38
38
  const chatAvailability = getAllCachedChatAvailability();
39
39
  const chatProviders = getAllChatProviders().map(p => ({
40
- id: p.id, name: p.name, available: chatAvailability[p.id]?.available || false
40
+ id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
41
41
  }));
42
42
 
43
43
  // Only return safe configuration values (not secrets like github_token)
package/src/routes/pr.js CHANGED
@@ -300,7 +300,11 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
300
300
  }
301
301
 
302
302
  // Fetch fresh PR data from GitHub
303
- const githubClient = new GitHubClient(config.github_token);
303
+ const githubToken = getGitHubToken(config);
304
+ if (!githubToken) {
305
+ return res.status(401).json({ error: 'GitHub token not configured' });
306
+ }
307
+ const githubClient = new GitHubClient(githubToken);
304
308
  const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
305
309
 
306
310
  // Update worktree with latest changes
@@ -467,7 +471,11 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
467
471
  }
468
472
 
469
473
  // Fetch current PR from GitHub
470
- const githubClient = new GitHubClient(config.github_token);
474
+ const githubToken = getGitHubToken(config);
475
+ if (!githubToken) {
476
+ return res.json({ isStale: null, error: 'GitHub token not configured' });
477
+ }
478
+ const githubClient = new GitHubClient(githubToken);
471
479
  const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
472
480
 
473
481
  const remoteHeadSha = remotePrData.head_sha;