@in-the-loop-labs/pair-review 2.4.4 → 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.4",
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.4",
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.4",
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
@@ -11066,6 +11066,39 @@ body.resizing * {
11066
11066
  background: linear-gradient(to bottom, var(--chat-subtle), transparent);
11067
11067
  flex-shrink: 0;
11068
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
+
11069
11102
  /* Provider picker */
11070
11103
  .chat-panel__provider-picker {
11071
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;
@@ -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;