@in-the-loop-labs/pair-review 3.1.3 → 3.2.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 +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +980 -3
- package/public/js/components/AIPanel.js +7 -4
- package/public/js/components/ChatPanel.js +34 -4
- package/public/js/components/CouncilProgressModal.js +11 -0
- package/public/js/components/NotificationDropdown.js +257 -0
- package/public/js/components/StackAnalysisDialog.js +313 -0
- package/public/js/components/StackProgressModal.js +475 -0
- package/public/js/components/StatusIndicator.js +1 -0
- package/public/js/components/SuggestionNavigator.js +2 -0
- package/public/js/modules/comment-manager.js +7 -0
- package/public/js/modules/comment-minimizer.js +151 -4
- package/public/js/modules/file-comment-manager.js +66 -2
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +433 -2
- package/public/js/utils/notification-sounds.js +62 -0
- package/public/local.html +10 -0
- package/public/pr.html +12 -0
- package/public/setup.html +4 -0
- package/src/ai/claude-provider.js +1 -11
- package/src/ai/codex-provider.js +18 -16
- package/src/ai/copilot-provider.js +21 -21
- package/src/ai/gemini-provider.js +10 -0
- package/src/ai/pi-provider.js +22 -25
- package/src/ai/provider.js +26 -3
- package/src/chat/pi-bridge.js +8 -0
- package/src/chat/session-manager.js +1 -0
- package/src/git/base-branch.js +1 -51
- package/src/git/worktree-lock.js +88 -0
- package/src/git/worktree.js +64 -0
- package/src/github/stack-walker.js +196 -0
- package/src/routes/local.js +12 -8
- package/src/routes/pr.js +139 -26
- package/src/routes/sound.js +49 -0
- package/src/routes/stack-analysis.js +886 -0
- package/src/server.js +4 -0
- package/src/setup/stack-setup.js +77 -0
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/utils/notification-sounds.js"></script>
|
|
531
532
|
|
|
532
533
|
<script>
|
|
533
534
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
@@ -818,6 +819,9 @@
|
|
|
818
819
|
updateProgressBar();
|
|
819
820
|
showRedirect();
|
|
820
821
|
|
|
822
|
+
// Play notification sound for PR setup completion
|
|
823
|
+
if (window.notificationSounds && mode !== 'local') window.notificationSounds.playIfEnabled('setup');
|
|
824
|
+
|
|
821
825
|
// Small delay so the user sees the completed state
|
|
822
826
|
setTimeout(function() {
|
|
823
827
|
if (msg.reviewUrl) {
|
|
@@ -22,23 +22,13 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
22
22
|
const CLAUDE_MODELS = [
|
|
23
23
|
{
|
|
24
24
|
id: 'haiku',
|
|
25
|
-
name: 'Haiku 4.
|
|
25
|
+
name: 'Haiku 4.6',
|
|
26
26
|
tier: 'fast',
|
|
27
27
|
tagline: 'Lightning Fast',
|
|
28
28
|
description: 'Quick analysis for simple changes',
|
|
29
29
|
badge: 'Fastest',
|
|
30
30
|
badgeClass: 'badge-speed'
|
|
31
31
|
},
|
|
32
|
-
{
|
|
33
|
-
id: 'sonnet-4.5',
|
|
34
|
-
cli_model: 'claude-sonnet-4.5',
|
|
35
|
-
name: 'Sonnet 4.5',
|
|
36
|
-
tier: 'balanced',
|
|
37
|
-
tagline: 'Previous Gen',
|
|
38
|
-
description: 'Sonnet 4.5 — previous generation balanced model',
|
|
39
|
-
badge: 'Previous Gen',
|
|
40
|
-
badgeClass: 'badge-balanced'
|
|
41
|
-
},
|
|
42
32
|
{
|
|
43
33
|
id: 'sonnet-4.6',
|
|
44
34
|
cli_model: 'claude-sonnet-4-6',
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -21,27 +21,29 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
21
21
|
* Codex model definitions with tier mappings
|
|
22
22
|
*
|
|
23
23
|
* Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
|
|
24
|
-
* - gpt-5.
|
|
25
|
-
* - gpt-5.
|
|
26
|
-
* - gpt-5.
|
|
27
|
-
* - gpt-5.
|
|
24
|
+
* - gpt-5.4-nano: Cheapest model ($0.20/$1.25 per MTok), good for surface scans
|
|
25
|
+
* - gpt-5.4-mini: Fast with 400k context ($0.75/$4.50 per MTok)
|
|
26
|
+
* - gpt-5.4: Flagship model combining coding, reasoning, and agentic workflows
|
|
27
|
+
* - gpt-5.3-codex: Industry-leading coding model for complex engineering tasks
|
|
28
|
+
*
|
|
29
|
+
* Deprecated (April 2026): gpt-5.1-codex-mini, gpt-5.1-codex-max, gpt-5.1-codex
|
|
28
30
|
*/
|
|
29
31
|
const CODEX_MODELS = [
|
|
30
32
|
{
|
|
31
|
-
id: 'gpt-5.
|
|
32
|
-
name: 'GPT-5.
|
|
33
|
+
id: 'gpt-5.4-nano',
|
|
34
|
+
name: 'GPT-5.4 Nano',
|
|
33
35
|
tier: 'fast',
|
|
34
|
-
tagline: '
|
|
35
|
-
description: '
|
|
36
|
-
badge: '
|
|
36
|
+
tagline: 'Cheapest',
|
|
37
|
+
description: 'Ultra-low-cost surface scans for style issues, obvious bugs, and lint-level feedback.',
|
|
38
|
+
badge: 'Cheapest',
|
|
37
39
|
badgeClass: 'badge-speed'
|
|
38
40
|
},
|
|
39
41
|
{
|
|
40
|
-
id: 'gpt-5.
|
|
41
|
-
name: 'GPT-5.
|
|
42
|
+
id: 'gpt-5.4-mini',
|
|
43
|
+
name: 'GPT-5.4 Mini',
|
|
42
44
|
tier: 'balanced',
|
|
43
45
|
tagline: 'Best Balance',
|
|
44
|
-
description: '
|
|
46
|
+
description: 'Fast reviews with 400k context—good balance of speed and capability for everyday PR review.',
|
|
45
47
|
badge: 'Recommended',
|
|
46
48
|
badgeClass: 'badge-recommended',
|
|
47
49
|
default: true
|
|
@@ -51,7 +53,7 @@ const CODEX_MODELS = [
|
|
|
51
53
|
name: 'GPT-5.3 Codex',
|
|
52
54
|
tier: 'thorough',
|
|
53
55
|
tagline: 'Deep Review',
|
|
54
|
-
description: '
|
|
56
|
+
description: 'Industry-leading coding model—frontier performance with strong reasoning for cross-file analysis.',
|
|
55
57
|
badge: 'Thorough',
|
|
56
58
|
badgeClass: 'badge-power'
|
|
57
59
|
},
|
|
@@ -60,7 +62,7 @@ const CODEX_MODELS = [
|
|
|
60
62
|
name: 'GPT-5.4',
|
|
61
63
|
tier: 'thorough',
|
|
62
64
|
tagline: 'Latest Gen',
|
|
63
|
-
description: '
|
|
65
|
+
description: 'Flagship model combining coding, reasoning, and agentic workflows for complex architectural reviews.',
|
|
64
66
|
badge: 'Most Thorough',
|
|
65
67
|
badgeClass: 'badge-power'
|
|
66
68
|
}
|
|
@@ -76,7 +78,7 @@ class CodexProvider extends AIProvider {
|
|
|
76
78
|
* @param {Object} configOverrides.env - Additional environment variables
|
|
77
79
|
* @param {Object[]} configOverrides.models - Custom model definitions
|
|
78
80
|
*/
|
|
79
|
-
constructor(model = 'gpt-5.
|
|
81
|
+
constructor(model = 'gpt-5.4-mini', configOverrides = {}) {
|
|
80
82
|
super(model);
|
|
81
83
|
|
|
82
84
|
// Command precedence: ENV > config > default
|
|
@@ -698,7 +700,7 @@ class CodexProvider extends AIProvider {
|
|
|
698
700
|
}
|
|
699
701
|
|
|
700
702
|
static getDefaultModel() {
|
|
701
|
-
return 'gpt-5.
|
|
703
|
+
return 'gpt-5.4-mini';
|
|
702
704
|
}
|
|
703
705
|
|
|
704
706
|
static getInstallInstructions() {
|
|
@@ -21,21 +21,30 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
21
21
|
*
|
|
22
22
|
* GitHub Copilot CLI supports multiple AI models including OpenAI,
|
|
23
23
|
* Anthropic, and Google models via the --model flag.
|
|
24
|
-
* Available models (as of
|
|
25
|
-
* claude-sonnet-4.5, gpt-5.
|
|
24
|
+
* Available models (as of April 2026): claude-haiku-4.6, claude-sonnet-4.6,
|
|
25
|
+
* claude-sonnet-4.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex,
|
|
26
26
|
* claude-opus-4.5, claude-opus-4.6, claude-opus-4.6-fast.
|
|
27
27
|
* Default is claude-sonnet-4.6.
|
|
28
28
|
*/
|
|
29
29
|
const COPILOT_MODELS = [
|
|
30
30
|
{
|
|
31
|
-
id: 'claude-haiku-4.
|
|
32
|
-
name: 'Claude Haiku 4.
|
|
31
|
+
id: 'claude-haiku-4.6',
|
|
32
|
+
name: 'Claude Haiku 4.6',
|
|
33
33
|
tier: 'fast',
|
|
34
34
|
tagline: 'Quick Scan',
|
|
35
35
|
description: 'Rapid feedback for obvious issues, style checks, and simple logic errors',
|
|
36
36
|
badge: 'Speedy',
|
|
37
37
|
badgeClass: 'badge-speed'
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
id: 'gpt-5.4-mini',
|
|
41
|
+
name: 'GPT-5.4 Mini',
|
|
42
|
+
tier: 'fast',
|
|
43
|
+
tagline: 'Fast & Cheap',
|
|
44
|
+
description: 'Low-cost fast reviews with solid reasoning—included at no premium cost',
|
|
45
|
+
badge: 'Fast',
|
|
46
|
+
badgeClass: 'badge-speed'
|
|
47
|
+
},
|
|
39
48
|
{
|
|
40
49
|
id: 'claude-sonnet-4.6',
|
|
41
50
|
name: 'Claude Sonnet 4.6',
|
|
@@ -47,29 +56,20 @@ const COPILOT_MODELS = [
|
|
|
47
56
|
default: true
|
|
48
57
|
},
|
|
49
58
|
{
|
|
50
|
-
id: '
|
|
51
|
-
name: '
|
|
52
|
-
tier: '
|
|
53
|
-
tagline: '
|
|
54
|
-
description: '
|
|
55
|
-
badge: '
|
|
56
|
-
badgeClass: 'badge-
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
id: 'gpt-5.2-codex',
|
|
60
|
-
name: 'GPT-5.2 Codex',
|
|
61
|
-
tier: 'balanced',
|
|
62
|
-
tagline: 'Alternative View',
|
|
63
|
-
description: 'OpenAI code-specialized model—different perspective for cross-file analysis',
|
|
64
|
-
badge: 'Balanced',
|
|
65
|
-
badgeClass: 'badge-balanced'
|
|
59
|
+
id: 'gpt-5.4',
|
|
60
|
+
name: 'GPT-5.4',
|
|
61
|
+
tier: 'thorough',
|
|
62
|
+
tagline: 'Latest OpenAI',
|
|
63
|
+
description: 'Flagship OpenAI model combining coding, reasoning, and agentic workflows',
|
|
64
|
+
badge: 'Latest',
|
|
65
|
+
badgeClass: 'badge-power'
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
id: 'gpt-5.3-codex',
|
|
69
69
|
name: 'GPT-5.3 Codex',
|
|
70
70
|
tier: 'thorough',
|
|
71
71
|
tagline: 'Deep Code Analysis',
|
|
72
|
-
description: '
|
|
72
|
+
description: 'Industry-leading coding model—frontier performance for complex multi-file reviews',
|
|
73
73
|
badge: 'Thorough',
|
|
74
74
|
badgeClass: 'badge-power'
|
|
75
75
|
},
|
|
@@ -20,6 +20,16 @@ const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
|
20
20
|
* Gemini model definitions with tier mappings
|
|
21
21
|
*/
|
|
22
22
|
const GEMINI_MODELS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'gemini-3.1-flash-lite-preview',
|
|
25
|
+
aliases: ['gemini-3.1-flash-lite'],
|
|
26
|
+
name: '3.1 Flash Lite',
|
|
27
|
+
tier: 'fast',
|
|
28
|
+
tagline: 'Cheapest',
|
|
29
|
+
description: 'Ultra-efficient model for high-volume cost-conscious scans',
|
|
30
|
+
badge: 'Cheapest',
|
|
31
|
+
badgeClass: 'badge-speed'
|
|
32
|
+
},
|
|
23
33
|
{
|
|
24
34
|
id: 'gemini-3-flash-preview',
|
|
25
35
|
aliases: ['gemini-3-flash'],
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* for cross-provider switching, which translates to `--provider <provider> --model <model>`.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
const crypto = require('crypto');
|
|
19
20
|
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const fs = require('fs');
|
|
20
23
|
const { spawn } = require('child_process');
|
|
21
24
|
const { AIProvider, registerProvider, quoteShellArgs } = require('./provider');
|
|
22
25
|
const logger = require('../utils/logger');
|
|
@@ -244,19 +247,23 @@ class PiProvider extends AIProvider {
|
|
|
244
247
|
|
|
245
248
|
const levelPrefix = logPrefix || `[Level ${level}]`;
|
|
246
249
|
logger.info(`${levelPrefix} Executing Pi CLI...`);
|
|
247
|
-
logger.info(`${levelPrefix}
|
|
250
|
+
logger.info(`${levelPrefix} Prompt: ${prompt.length} bytes`);
|
|
251
|
+
|
|
252
|
+
// Write prompt to a temp file and use Pi's @file syntax as a positional arg.
|
|
253
|
+
// This bypasses devx stdin interference that breaks --mode json output.
|
|
254
|
+
const tmpFile = path.join(os.tmpdir(), `pair-review-prompt-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
255
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
256
|
+
const cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
|
|
248
257
|
|
|
249
|
-
// Use stdin for prompt instead of CLI argument (avoids shell escaping issues)
|
|
250
|
-
// Pi reads from stdin when using -p with no positional message arguments
|
|
251
258
|
let fullCommand;
|
|
252
259
|
let fullArgs;
|
|
253
260
|
|
|
254
261
|
if (this.useShell) {
|
|
255
|
-
fullCommand = `${this.piCmd} ${quoteShellArgs(this.baseArgs).join(' ')}`;
|
|
262
|
+
fullCommand = `${this.piCmd} ${quoteShellArgs([...this.baseArgs, `@${tmpFile}`]).join(' ')}`;
|
|
256
263
|
fullArgs = [];
|
|
257
264
|
} else {
|
|
258
265
|
fullCommand = this.piCmd;
|
|
259
|
-
fullArgs = [...this.baseArgs];
|
|
266
|
+
fullArgs = [...this.baseArgs, `@${tmpFile}`];
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
const pi = spawn(fullCommand, fullArgs, {
|
|
@@ -269,6 +276,10 @@ class PiProvider extends AIProvider {
|
|
|
269
276
|
shell: this.useShell
|
|
270
277
|
});
|
|
271
278
|
|
|
279
|
+
// Close stdin immediately — prompt is delivered via @file, but some
|
|
280
|
+
// wrappers (e.g., devx) keep the process alive until stdin is closed.
|
|
281
|
+
pi.stdin.end();
|
|
282
|
+
|
|
272
283
|
const pid = pi.pid;
|
|
273
284
|
logger.debug(`${levelPrefix} Pi CLI command: ${fullCommand} ${fullArgs.join(' ')}`);
|
|
274
285
|
logger.info(`${levelPrefix} Spawned Pi CLI process: PID ${pid}`);
|
|
@@ -340,6 +351,7 @@ class PiProvider extends AIProvider {
|
|
|
340
351
|
|
|
341
352
|
// Handle completion
|
|
342
353
|
pi.on('close', (code) => {
|
|
354
|
+
cleanupTmpFile();
|
|
343
355
|
if (settled) return; // Already settled by timeout or error
|
|
344
356
|
|
|
345
357
|
// Flush any remaining stream parser buffer
|
|
@@ -413,7 +425,7 @@ class PiProvider extends AIProvider {
|
|
|
413
425
|
|
|
414
426
|
// Use async IIFE to handle the async LLM extraction
|
|
415
427
|
(async () => {
|
|
416
|
-
// Guard: if already settled (by timeout,
|
|
428
|
+
// Guard: if already settled (by timeout, process error, or cancellation),
|
|
417
429
|
// skip the LLM extraction entirely to avoid misleading log output
|
|
418
430
|
if (settled) return;
|
|
419
431
|
|
|
@@ -437,6 +449,7 @@ class PiProvider extends AIProvider {
|
|
|
437
449
|
|
|
438
450
|
// Handle errors
|
|
439
451
|
pi.on('error', (error) => {
|
|
452
|
+
cleanupTmpFile();
|
|
440
453
|
if (error.code === 'ENOENT') {
|
|
441
454
|
logger.error(`${levelPrefix} Pi CLI not found. Please ensure Pi CLI is installed.`);
|
|
442
455
|
settle(reject, new Error(`${levelPrefix} Pi CLI not found. ${PiProvider.getInstallInstructions()}`));
|
|
@@ -445,21 +458,6 @@ class PiProvider extends AIProvider {
|
|
|
445
458
|
settle(reject, error);
|
|
446
459
|
}
|
|
447
460
|
});
|
|
448
|
-
|
|
449
|
-
// Handle stdin errors (e.g., EPIPE if process exits before write completes)
|
|
450
|
-
pi.stdin.on('error', (err) => {
|
|
451
|
-
logger.error(`${levelPrefix} stdin error: ${err.message}`);
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
// Send the prompt to stdin (Pi reads from stdin when using -p with no args)
|
|
455
|
-
pi.stdin.write(prompt, (err) => {
|
|
456
|
-
if (err) {
|
|
457
|
-
logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
|
|
458
|
-
pi.kill('SIGTERM');
|
|
459
|
-
settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
|
|
460
|
-
}
|
|
461
|
-
});
|
|
462
|
-
pi.stdin.end();
|
|
463
461
|
});
|
|
464
462
|
}
|
|
465
463
|
|
|
@@ -740,14 +738,13 @@ class PiProvider extends AIProvider {
|
|
|
740
738
|
// Build args consistently using the shared method, applying provider and model extra_args
|
|
741
739
|
const args = this.buildArgsForModel(model);
|
|
742
740
|
|
|
743
|
-
//
|
|
744
|
-
// Pi reads from stdin when using -p with no positional message arguments
|
|
741
|
+
// Use @file syntax for prompt delivery (bypasses devx stdin interference)
|
|
745
742
|
if (useShell) {
|
|
746
743
|
return {
|
|
747
744
|
command: `${piCmd} ${quoteShellArgs(args).join(' ')}`,
|
|
748
745
|
args: [],
|
|
749
746
|
useShell: true,
|
|
750
|
-
|
|
747
|
+
promptViaFile: true,
|
|
751
748
|
env: this.extraEnv
|
|
752
749
|
};
|
|
753
750
|
}
|
|
@@ -755,7 +752,7 @@ class PiProvider extends AIProvider {
|
|
|
755
752
|
command: piCmd,
|
|
756
753
|
args,
|
|
757
754
|
useShell: false,
|
|
758
|
-
|
|
755
|
+
promptViaFile: true,
|
|
759
756
|
env: this.extraEnv
|
|
760
757
|
};
|
|
761
758
|
}
|
package/src/ai/provider.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* and provides a factory function to create provider instances.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
const crypto = require('crypto');
|
|
9
10
|
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const fs = require('fs');
|
|
10
13
|
const { spawn } = require('child_process');
|
|
11
14
|
const logger = require('../utils/logger');
|
|
12
15
|
const { extractJSON } = require('../utils/json-extractor');
|
|
@@ -181,6 +184,7 @@ class AIProvider {
|
|
|
181
184
|
* @property {string[]} args - Arguments (prompt will be appended if promptViaStdin is false)
|
|
182
185
|
* @property {boolean} useShell - Whether to use shell mode
|
|
183
186
|
* @property {boolean} promptViaStdin - If true, send prompt to stdin; if false, append to args
|
|
187
|
+
* @property {boolean} promptViaFile - If true, write prompt to a temp file and pass @filepath as a positional arg (Pi-specific @file syntax; currently only used by PiProvider)
|
|
184
188
|
*/
|
|
185
189
|
getExtractionConfig(model) {
|
|
186
190
|
// Default: extraction not supported
|
|
@@ -213,7 +217,7 @@ class AIProvider {
|
|
|
213
217
|
};
|
|
214
218
|
}
|
|
215
219
|
|
|
216
|
-
const { command, args, useShell, promptViaStdin, env: configEnv } = config;
|
|
220
|
+
const { command, args, useShell, promptViaStdin, promptViaFile, env: configEnv } = config;
|
|
217
221
|
const prompt = `Extract the JSON object from the following text. Return ONLY the valid JSON, nothing else. Do not include any explanation, markdown formatting, or code blocks - just the raw JSON.
|
|
218
222
|
|
|
219
223
|
=== BEGIN INPUT TEXT ===
|
|
@@ -222,7 +226,21 @@ ${rawResponse}
|
|
|
222
226
|
|
|
223
227
|
return new Promise((resolve) => {
|
|
224
228
|
// Build final command and args based on prompt delivery method
|
|
225
|
-
|
|
229
|
+
// promptViaFile: write to temp file, pass @filepath as positional arg (Pi @file syntax)
|
|
230
|
+
// promptViaStdin: write to process stdin after spawn
|
|
231
|
+
// default: pass prompt as positional CLI arg
|
|
232
|
+
let tmpFile = null;
|
|
233
|
+
let cleanupTmpFile = () => {};
|
|
234
|
+
let finalArgs;
|
|
235
|
+
|
|
236
|
+
if (promptViaFile) {
|
|
237
|
+
tmpFile = path.join(os.tmpdir(), `pair-review-extract-${Date.now()}-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
238
|
+
fs.writeFileSync(tmpFile, prompt);
|
|
239
|
+
cleanupTmpFile = () => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } };
|
|
240
|
+
finalArgs = [...args, `@${tmpFile}`];
|
|
241
|
+
} else {
|
|
242
|
+
finalArgs = promptViaStdin ? args : [...args, prompt];
|
|
243
|
+
}
|
|
226
244
|
|
|
227
245
|
logger.info(`${levelPrefix} Attempting LLM-based JSON extraction with ${extractionModel}...`);
|
|
228
246
|
|
|
@@ -269,6 +287,7 @@ ${rawResponse}
|
|
|
269
287
|
});
|
|
270
288
|
|
|
271
289
|
proc.on('close', (code) => {
|
|
290
|
+
cleanupTmpFile();
|
|
272
291
|
if (settled) return;
|
|
273
292
|
|
|
274
293
|
if (code !== 0) {
|
|
@@ -295,11 +314,12 @@ ${rawResponse}
|
|
|
295
314
|
});
|
|
296
315
|
|
|
297
316
|
proc.on('error', (error) => {
|
|
317
|
+
cleanupTmpFile();
|
|
298
318
|
logger.warn(`${levelPrefix} LLM extraction process error: ${error.message}`);
|
|
299
319
|
settle({ success: false, error: error.message });
|
|
300
320
|
});
|
|
301
321
|
|
|
302
|
-
//
|
|
322
|
+
// Deliver prompt based on config method
|
|
303
323
|
if (promptViaStdin) {
|
|
304
324
|
// Handle stdin errors (e.g., EPIPE if process exits before write completes)
|
|
305
325
|
proc.stdin.on('error', (err) => {
|
|
@@ -314,6 +334,9 @@ ${rawResponse}
|
|
|
314
334
|
}
|
|
315
335
|
});
|
|
316
336
|
proc.stdin.end();
|
|
337
|
+
} else if (promptViaFile) {
|
|
338
|
+
// Prompt delivered via @file arg — close stdin so wrappers (e.g., devx) don't hang
|
|
339
|
+
proc.stdin.end();
|
|
317
340
|
}
|
|
318
341
|
});
|
|
319
342
|
}
|
package/src/chat/pi-bridge.js
CHANGED
|
@@ -35,6 +35,7 @@ class PiBridge extends EventEmitter {
|
|
|
35
35
|
* @param {boolean} [options.useShell] - Use shell mode for multi-word commands
|
|
36
36
|
* @param {string[]} [options.skills] - Array of skill file paths to load via --skill
|
|
37
37
|
* @param {string[]} [options.extensions] - Array of extension directory paths to load via -e
|
|
38
|
+
* @param {string[]} [options.extraArgs] - Extra CLI args to append (e.g., from config extra_args)
|
|
38
39
|
* @param {string} [options.sessionPath] - Path to a session file for resumption
|
|
39
40
|
*/
|
|
40
41
|
constructor(options = {}) {
|
|
@@ -49,6 +50,7 @@ class PiBridge extends EventEmitter {
|
|
|
49
50
|
this.useShell = options.useShell || false;
|
|
50
51
|
this.skills = options.skills || [];
|
|
51
52
|
this.extensions = options.extensions || [];
|
|
53
|
+
this.extraArgs = options.extraArgs || [];
|
|
52
54
|
this.sessionPath = options.sessionPath || null;
|
|
53
55
|
|
|
54
56
|
this._process = null;
|
|
@@ -288,6 +290,12 @@ class PiBridge extends EventEmitter {
|
|
|
288
290
|
args.push('-e', ext);
|
|
289
291
|
}
|
|
290
292
|
|
|
293
|
+
// Append extra args from provider config (e.g., extra_args in chat_providers).
|
|
294
|
+
// These go last so they can override earlier flags if needed.
|
|
295
|
+
if (this.extraArgs.length > 0) {
|
|
296
|
+
args.push(...this.extraArgs);
|
|
297
|
+
}
|
|
298
|
+
|
|
291
299
|
return args;
|
|
292
300
|
}
|
|
293
301
|
|
package/src/git/base-branch.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const { execSync } = require('child_process');
|
|
3
|
-
const { readFileSync } = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
3
|
const logger = require('../utils/logger');
|
|
6
4
|
|
|
7
5
|
const defaults = {
|
|
8
6
|
execSync,
|
|
9
|
-
readFileSync,
|
|
10
7
|
// Callers should pass a resolved token via _deps.getGitHubToken.
|
|
11
8
|
// This default returns empty so GitHub lookup is silently skipped
|
|
12
9
|
// when no token is provided — never re-resolve config internally.
|
|
@@ -135,53 +132,6 @@ function buildStack(state, currentBranch, trunk) {
|
|
|
135
132
|
return entries;
|
|
136
133
|
}
|
|
137
134
|
|
|
138
|
-
/**
|
|
139
|
-
* Read Graphite PR info from the `.graphite_pr_info` file in the git dir.
|
|
140
|
-
*
|
|
141
|
-
* @param {string} repoPath - Absolute path to the repository
|
|
142
|
-
* @param {Object} deps - Dependencies (execSync, readFileSync)
|
|
143
|
-
* @returns {Object|null} Parsed PR info object with `prInfos` array, or null
|
|
144
|
-
*/
|
|
145
|
-
function readGraphitePRInfo(repoPath, deps) {
|
|
146
|
-
try {
|
|
147
|
-
const gitCommonDir = deps.execSync('git rev-parse --git-common-dir', {
|
|
148
|
-
cwd: repoPath,
|
|
149
|
-
encoding: 'utf8',
|
|
150
|
-
stdio: ['pipe', 'pipe', 'pipe']
|
|
151
|
-
}).trim();
|
|
152
|
-
|
|
153
|
-
const prInfoPath = path.resolve(repoPath, gitCommonDir, '.graphite_pr_info');
|
|
154
|
-
const raw = deps.readFileSync(prInfoPath, 'utf8');
|
|
155
|
-
return JSON.parse(raw);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
logger.debug(`Graphite PR info read failed: ${error.message}`);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Enrich stack entries with PR numbers from Graphite PR info.
|
|
164
|
-
*
|
|
165
|
-
* @param {Array} stack - Stack entries from buildStack
|
|
166
|
-
* @param {Array} prInfos - Array of PR info objects with headRefName and prNumber
|
|
167
|
-
* @returns {Array} New array of stack entries, each with optional prNumber
|
|
168
|
-
*/
|
|
169
|
-
function enrichStackWithPRInfo(stack, prInfos) {
|
|
170
|
-
if (!prInfos || !Array.isArray(prInfos)) return stack;
|
|
171
|
-
|
|
172
|
-
const prMap = new Map();
|
|
173
|
-
for (const info of prInfos) {
|
|
174
|
-
if (info.headRefName) {
|
|
175
|
-
prMap.set(info.headRefName, info.prNumber);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return stack.map(entry => {
|
|
180
|
-
const prNumber = prMap.get(entry.branch);
|
|
181
|
-
return prNumber != null ? { ...entry, prNumber } : { ...entry };
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
|
|
185
135
|
/**
|
|
186
136
|
* Try GitHub API to find an open PR for this branch.
|
|
187
137
|
*/
|
|
@@ -302,4 +252,4 @@ function getDefaultBranch(localPath, _deps) {
|
|
|
302
252
|
return null;
|
|
303
253
|
}
|
|
304
254
|
|
|
305
|
-
module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack
|
|
255
|
+
module.exports = { detectBaseBranch, getDefaultBranch, tryGraphiteState, buildStack };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* In-memory worktree lock manager.
|
|
4
|
+
*
|
|
5
|
+
* Prevents concurrent git operations on the same worktree during
|
|
6
|
+
* stack analysis. Non-blocking — callers check and fail fast.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const logger = require('../utils/logger');
|
|
10
|
+
|
|
11
|
+
class WorktreeLockManager {
|
|
12
|
+
constructor() {
|
|
13
|
+
/** @type {Map<string, { holderId: string, lockedAt: Date }>} */
|
|
14
|
+
this._locks = new Map();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Acquire a lock on a worktree path.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
21
|
+
* @param {string} holderId - Unique identifier for the lock holder (e.g. stackAnalysisId)
|
|
22
|
+
* @returns {boolean} true if acquired (or re-acquired by same holder), false if held by another
|
|
23
|
+
*/
|
|
24
|
+
acquire(worktreePath, holderId) {
|
|
25
|
+
const existing = this._locks.get(worktreePath);
|
|
26
|
+
|
|
27
|
+
if (existing) {
|
|
28
|
+
if (existing.holderId === holderId) {
|
|
29
|
+
// Re-acquire by same holder — update timestamp
|
|
30
|
+
existing.lockedAt = new Date();
|
|
31
|
+
logger.debug(`Worktree lock re-acquired: ${worktreePath} by ${holderId}`);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
logger.debug(`Worktree lock denied: ${worktreePath} held by ${existing.holderId}, requested by ${holderId}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
this._locks.set(worktreePath, { holderId, lockedAt: new Date() });
|
|
39
|
+
logger.info(`Worktree lock acquired: ${worktreePath} by ${holderId}`);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Release a lock on a worktree path.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
47
|
+
* @param {string} holderId - Must match the holder that acquired the lock
|
|
48
|
+
* @returns {boolean} true if released, false if not held or held by a different holder
|
|
49
|
+
*/
|
|
50
|
+
release(worktreePath, holderId) {
|
|
51
|
+
const existing = this._locks.get(worktreePath);
|
|
52
|
+
|
|
53
|
+
if (!existing) {
|
|
54
|
+
logger.debug(`Worktree lock release: no lock found for ${worktreePath}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (existing.holderId !== holderId) {
|
|
59
|
+
logger.debug(`Worktree lock release denied: ${worktreePath} held by ${existing.holderId}, release requested by ${holderId}`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this._locks.delete(worktreePath);
|
|
64
|
+
logger.info(`Worktree lock released: ${worktreePath} by ${holderId}`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check whether a worktree is currently locked.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} worktreePath - Absolute path to the worktree
|
|
72
|
+
* @returns {{ locked: boolean, holderId?: string }}
|
|
73
|
+
*/
|
|
74
|
+
isLocked(worktreePath) {
|
|
75
|
+
const existing = this._locks.get(worktreePath);
|
|
76
|
+
|
|
77
|
+
if (!existing) {
|
|
78
|
+
return { locked: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { locked: true, holderId: existing.holderId };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Singleton instance for application-wide use
|
|
86
|
+
const worktreeLock = new WorktreeLockManager();
|
|
87
|
+
|
|
88
|
+
module.exports = { worktreeLock, WorktreeLockManager };
|