@in-the-loop-labs/pair-review 2.4.4 → 2.6.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/.pi/extensions/task/index.ts +5 -2
- package/config.managed.json +1 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +33 -0
- package/public/js/components/ChatPanel.js +79 -1
- package/src/ai/claude-provider.js +8 -1
- package/src/ai/codex-provider.js +12 -2
- package/src/ai/cursor-agent-provider.js +18 -0
- package/src/ai/pi-provider.js +6 -7
- package/src/chat/chat-providers.js +36 -9
- package/src/chat/pi-bridge.js +3 -6
- package/src/config.js +47 -3
- package/src/main.js +10 -5
- package/src/routes/config.js +1 -1
- package/src/routes/pr.js +10 -2
|
@@ -205,10 +205,13 @@ async function runTask(
|
|
|
205
205
|
onUpdate: OnUpdate | undefined,
|
|
206
206
|
makeDetails: (results: TaskResult[]) => TaskDetails,
|
|
207
207
|
): Promise<TaskResult> {
|
|
208
|
-
// Build args: full tool access, JSON output, no session persistence
|
|
208
|
+
// Build args: full tool access, JSON output, no session persistence.
|
|
209
|
+
// Skills and extensions are left enabled so subtasks have access to the
|
|
210
|
+
// user's configured environment. --no-prompt-templates is kept because
|
|
211
|
+
// prompt templates can't be triggered in -p mode.
|
|
209
212
|
const args: string[] = [
|
|
210
213
|
"--mode", "json", "-p", "--no-session",
|
|
211
|
-
"--no-
|
|
214
|
+
"--no-prompt-templates",
|
|
212
215
|
"-e", EXTENSION_DIR,
|
|
213
216
|
];
|
|
214
217
|
if (model) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@in-the-loop-labs/pair-review",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"public/",
|
|
15
15
|
"plugin/",
|
|
16
16
|
"plugin-code-critic/",
|
|
17
|
+
"config.managed.json",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.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.
|
|
3
|
+
"version": "2.6.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
|
-
|
|
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;
|
|
@@ -174,7 +174,14 @@ class ClaudeProvider extends AIProvider {
|
|
|
174
174
|
].join(',');
|
|
175
175
|
permissionArgs = ['--allowedTools', allowedTools];
|
|
176
176
|
}
|
|
177
|
-
|
|
177
|
+
// Suppress user hooks in analysis subprocesses to avoid side-effects
|
|
178
|
+
// (notifications, confirmations, etc.) firing on every tool call during review.
|
|
179
|
+
// Uses --settings '{"disableAllHooks":true}' since Claude has no --no-hooks flag.
|
|
180
|
+
// Skills and extensions are left enabled so the subprocess has access to the
|
|
181
|
+
// user's configured environment. To disable skills, add --disable-slash-commands
|
|
182
|
+
// to extra_args in provider/model config.
|
|
183
|
+
const hooksArgs = ['--settings', '{"disableAllHooks":true}'];
|
|
184
|
+
const baseArgs = ['-p', '--verbose', ...cliModelArgs, '--output-format', 'stream-json', ...hooksArgs, ...permissionArgs];
|
|
178
185
|
if (maxBudget) {
|
|
179
186
|
const budgetNum = parseFloat(maxBudget);
|
|
180
187
|
if (isNaN(budgetNum) || budgetNum <= 0) {
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -23,7 +23,8 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
23
23
|
* Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
|
|
24
24
|
* - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
|
|
25
25
|
* - gpt-5.2-codex: Advanced coding model for everyday reviews, good reasoning/cost balance
|
|
26
|
-
* - gpt-5.3-codex:
|
|
26
|
+
* - gpt-5.3-codex: Capable agentic coding model with frontier performance and reasoning
|
|
27
|
+
* - gpt-5.4: Latest generation with enhanced reasoning depth
|
|
27
28
|
*/
|
|
28
29
|
const CODEX_MODELS = [
|
|
29
30
|
{
|
|
@@ -50,7 +51,16 @@ const CODEX_MODELS = [
|
|
|
50
51
|
name: 'GPT-5.3 Codex',
|
|
51
52
|
tier: 'thorough',
|
|
52
53
|
tagline: 'Deep Review',
|
|
53
|
-
description: '
|
|
54
|
+
description: 'Capable agentic coding model—combines frontier coding performance with strong reasoning for cross-file analysis.',
|
|
55
|
+
badge: 'Thorough',
|
|
56
|
+
badgeClass: 'badge-power'
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'gpt-5.4',
|
|
60
|
+
name: 'GPT-5.4',
|
|
61
|
+
tier: 'thorough',
|
|
62
|
+
tagline: 'Latest Gen',
|
|
63
|
+
description: 'Latest generation model with enhanced reasoning depth for complex architectural reviews.',
|
|
54
64
|
badge: 'Most Thorough',
|
|
55
65
|
badgeClass: 'badge-power'
|
|
56
66
|
}
|
|
@@ -143,6 +143,24 @@ const CURSOR_AGENT_MODELS = [
|
|
|
143
143
|
description: 'Deep analysis with extended thinking—Cursor default for maximum review quality',
|
|
144
144
|
badge: 'Most Thorough',
|
|
145
145
|
badgeClass: 'badge-power'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'gpt-5.4-high',
|
|
149
|
+
name: 'GPT-5.4 High',
|
|
150
|
+
tier: 'thorough',
|
|
151
|
+
tagline: 'Latest OpenAI',
|
|
152
|
+
description: 'Latest generation with high reasoning effort—strong for complex architectural reviews',
|
|
153
|
+
badge: 'Latest Gen',
|
|
154
|
+
badgeClass: 'badge-power'
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'gpt-5.4-medium',
|
|
158
|
+
name: 'GPT-5.4',
|
|
159
|
+
tier: 'thorough',
|
|
160
|
+
tagline: 'Latest Gen',
|
|
161
|
+
description: 'Latest generation at medium reasoning depth',
|
|
162
|
+
badge: 'Latest',
|
|
163
|
+
badgeClass: 'badge-power'
|
|
146
164
|
}
|
|
147
165
|
];
|
|
148
166
|
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -189,24 +189,23 @@ class PiProvider extends AIProvider {
|
|
|
189
189
|
// --no-session: Each pi invocation is an ephemeral analysis — there's no need to
|
|
190
190
|
// persist session state between runs. Set PAIR_REVIEW_PI_SESSION=1
|
|
191
191
|
// to enable session saving for debugging (sessions saved to ~/.pi/sessions/).
|
|
192
|
-
// --no-skills: Skills are disabled by default to keep runs deterministic. A skill can
|
|
193
|
-
// still be loaded via `--skill` in model-specific `extra_args` if needed.
|
|
194
|
-
|
|
195
192
|
// Build args: base args + built-in extra_args + provider extra_args + model extra_args
|
|
196
193
|
// In yolo mode, omit --tools entirely to allow all tools (including edit, write)
|
|
197
194
|
// The task extension is loaded to give the model a subagent tool for delegating
|
|
198
195
|
// work to isolated subprocesses, preserving the main context window.
|
|
199
|
-
// --no-
|
|
200
|
-
//
|
|
196
|
+
// --no-prompt-templates: prompt templates can't be triggered in -p mode, so suppress
|
|
197
|
+
// them to avoid wasting context. Skills and extensions are left enabled so the
|
|
198
|
+
// subprocess has access to the user's configured environment. To disable them,
|
|
199
|
+
// add --no-skills or --no-extensions to extra_args in provider/model config.
|
|
201
200
|
const sessionArgs = process.env.PAIR_REVIEW_PI_SESSION ? [] : ['--no-session'];
|
|
202
201
|
let baseArgs;
|
|
203
202
|
if (configOverrides.yolo) {
|
|
204
203
|
baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, ...sessionArgs,
|
|
205
|
-
'--no-
|
|
204
|
+
'--no-prompt-templates',
|
|
206
205
|
'-e', TASK_EXTENSION_DIR];
|
|
207
206
|
} else {
|
|
208
207
|
baseArgs = ['-p', '--mode', 'json', ...cliModelArgs, '--tools', 'read,bash,grep,find,ls', ...sessionArgs,
|
|
209
|
-
'--no-
|
|
208
|
+
'--no-prompt-templates',
|
|
210
209
|
'-e', TASK_EXTENSION_DIR];
|
|
211
210
|
}
|
|
212
211
|
const builtInArgs = builtIn?.extra_args || [];
|
|
@@ -96,17 +96,40 @@ function applyConfigOverrides(providersConfig) {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Get a chat provider definition with config overrides merged.
|
|
99
|
-
*
|
|
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 (
|
|
151
|
+
* Get all chat provider definitions (built-in + dynamic from config).
|
|
129
152
|
* @returns {Array<Object>}
|
|
130
153
|
*/
|
|
131
154
|
function getAllChatProviders() {
|
|
132
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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/chat/pi-bridge.js
CHANGED
|
@@ -273,12 +273,9 @@ class PiBridge extends EventEmitter {
|
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
// Load extensions via -e (e.g., task extension for subagent delegation).
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
args.push('
|
|
279
|
-
for (const ext of this.extensions) {
|
|
280
|
-
args.push('-e', ext);
|
|
281
|
-
}
|
|
276
|
+
// These are additive — the user's auto-discovered extensions remain available.
|
|
277
|
+
for (const ext of this.extensions) {
|
|
278
|
+
args.push('-e', ext);
|
|
282
279
|
}
|
|
283
280
|
|
|
284
281
|
return args;
|
package/src/config.js
CHANGED
|
@@ -2,17 +2,22 @@
|
|
|
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');
|
|
10
13
|
const CONFIG_LOCAL_FILE = path.join(CONFIG_DIR, 'config.local.json');
|
|
11
14
|
const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
|
|
12
15
|
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
16
|
+
const MANAGED_CONFIG_FILE = path.join(PACKAGE_ROOT, 'config.managed.json');
|
|
13
17
|
|
|
14
18
|
const DEFAULT_CONFIG = {
|
|
15
19
|
github_token: "",
|
|
20
|
+
github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
|
|
16
21
|
port: 7247,
|
|
17
22
|
theme: "light",
|
|
18
23
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
@@ -171,6 +176,7 @@ async function loadConfig() {
|
|
|
171
176
|
|
|
172
177
|
const localDir = path.join(process.cwd(), '.pair-review');
|
|
173
178
|
const sources = [
|
|
179
|
+
{ path: MANAGED_CONFIG_FILE, label: 'managed config', required: false },
|
|
174
180
|
{ path: CONFIG_FILE, label: 'global config', required: true },
|
|
175
181
|
{ path: CONFIG_LOCAL_FILE, label: 'global local config', required: false },
|
|
176
182
|
{ path: path.join(localDir, 'config.json'), label: 'project config', required: false },
|
|
@@ -247,17 +253,54 @@ function getConfigDir() {
|
|
|
247
253
|
* Priority:
|
|
248
254
|
* 1. GITHUB_TOKEN environment variable (highest priority)
|
|
249
255
|
* 2. config.github_token from ~/.pair-review/config.json
|
|
256
|
+
* 3. config.github_token_command — execute shell command, use stdout (cached on success)
|
|
257
|
+
* 4. Empty string (no token)
|
|
250
258
|
*
|
|
251
259
|
* @param {Object} config - Configuration object from loadConfig()
|
|
252
260
|
* @returns {string} - GitHub token or empty string if not configured
|
|
253
261
|
*/
|
|
254
262
|
function getGitHubToken(config) {
|
|
255
|
-
// Environment variable takes precedence
|
|
256
263
|
if (process.env.GITHUB_TOKEN) {
|
|
264
|
+
logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
|
|
257
265
|
return process.env.GITHUB_TOKEN;
|
|
258
266
|
}
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
if (config.github_token) {
|
|
268
|
+
logger.debug('Using GitHub token from config.github_token');
|
|
269
|
+
return config.github_token;
|
|
270
|
+
}
|
|
271
|
+
if (config.github_token_command) {
|
|
272
|
+
if (_cachedCommandToken !== null) {
|
|
273
|
+
logger.debug('Using GitHub token from github_token_command (cached)');
|
|
274
|
+
return _cachedCommandToken;
|
|
275
|
+
}
|
|
276
|
+
logger.debug(`Attempting GitHub token from command: ${config.github_token_command}`);
|
|
277
|
+
try {
|
|
278
|
+
const result = childProcess.execSync(config.github_token_command, {
|
|
279
|
+
encoding: 'utf8',
|
|
280
|
+
timeout: 5000,
|
|
281
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
282
|
+
}).trim();
|
|
283
|
+
if (!result) {
|
|
284
|
+
logger.warn(`github_token_command did not produce a token (command: ${config.github_token_command})`);
|
|
285
|
+
return '';
|
|
286
|
+
}
|
|
287
|
+
logger.debug('Using GitHub token from github_token_command');
|
|
288
|
+
_cachedCommandToken = result;
|
|
289
|
+
return result;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
logger.warn(`github_token_command failed (command: ${config.github_token_command}): ${error.message}`);
|
|
292
|
+
return '';
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
logger.debug('No GitHub token configured');
|
|
296
|
+
return '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resets the cached command token. Exported for testing only.
|
|
301
|
+
*/
|
|
302
|
+
function _resetTokenCache() {
|
|
303
|
+
_cachedCommandToken = null;
|
|
261
304
|
}
|
|
262
305
|
|
|
263
306
|
/**
|
|
@@ -465,5 +508,6 @@ module.exports = {
|
|
|
465
508
|
resolveMonorepoOptions,
|
|
466
509
|
resolveDbName,
|
|
467
510
|
warnIfDevModeWithoutDbName,
|
|
511
|
+
_resetTokenCache,
|
|
468
512
|
DEFAULT_CHECKOUT_TIMEOUT_MS
|
|
469
513
|
};
|
package/src/main.js
CHANGED
|
@@ -318,7 +318,7 @@ CONFIG FILE:
|
|
|
318
318
|
|
|
319
319
|
Example config:
|
|
320
320
|
{
|
|
321
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
package/src/routes/config.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|