@in-the-loop-labs/pair-review 3.7.2 → 3.8.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/README.md +14 -0
- 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/js/local.js +1 -0
- package/public/js/pr.js +92 -7
- package/public/setup.html +8 -0
- package/src/ai/claude-provider.js +4 -3
- package/src/ai/codex-provider.js +4 -3
- package/src/ai/copilot-provider.js +15 -1
- package/src/ai/cursor-agent-provider.js +4 -3
- package/src/ai/executable-provider.js +12 -4
- package/src/ai/gemini-provider.js +15 -1
- package/src/ai/index.js +6 -0
- package/src/ai/opencode-provider.js +15 -1
- package/src/ai/pi-provider.js +4 -3
- package/src/ai/provider.js +69 -11
- package/src/chat/chat-providers.js +30 -9
- package/src/councils/headless-council.js +118 -0
- package/src/councils/resolve-council.js +167 -0
- package/src/local-review.js +13 -4
- package/src/main.js +209 -15
- package/src/single-port.js +20 -4
package/README.md
CHANGED
|
@@ -192,6 +192,8 @@ pair-review --local [path]
|
|
|
192
192
|
| `<PR-URL>` | Full GitHub PR URL (e.g., `https://github.com/owner/repo/pull/123`) |
|
|
193
193
|
| `--ai` | Automatically run AI analysis when the review loads |
|
|
194
194
|
| `--ai-draft` | Run AI analysis and save suggestions as a draft review on GitHub |
|
|
195
|
+
| `--council <handle>` | Run analysis with a saved multi-voice council. Implies analysis. The handle resolves by council name, name-slug, id (prefix), or a partial name fragment (resolving when it matches a single council, otherwise listing the candidates). When set, `--model` is ignored (council voices use their own per-voice models). Works in headless PR (`--ai-draft`/`--ai-review`), interactive PR (`--ai`), and local (`--local --ai`) modes. |
|
|
196
|
+
| `--list-councils` | List saved councils with their handles, names, types, and last-used repo, then exit. Use a printed handle with `--council`. |
|
|
195
197
|
| `--configure` | Show setup instructions and configuration options |
|
|
196
198
|
| `-d`, `--debug` | Enable verbose debug logging for troubleshooting |
|
|
197
199
|
| `-h`, `--help` | Show help message with full CLI documentation |
|
|
@@ -209,10 +211,20 @@ pair-review 123 # Review PR #123 in current repo
|
|
|
209
211
|
pair-review https://github.com/owner/repo/pull/456
|
|
210
212
|
pair-review --local # Review uncommitted local changes
|
|
211
213
|
pair-review 123 --ai # Auto-run AI analysis
|
|
214
|
+
pair-review --list-councils # List saved councils and their handles
|
|
215
|
+
pair-review 123 --ai-draft --council security-review # Headless draft with a council
|
|
216
|
+
pair-review --local --ai --council security-review # Local review with a council
|
|
212
217
|
pair-review --register # Register pair-review:// URL scheme (macOS)
|
|
213
218
|
pair-review --register --command "node bin/pair-review.js" # Custom command
|
|
214
219
|
```
|
|
215
220
|
|
|
221
|
+
> **Councils** are saved multi-voice review configurations created and managed
|
|
222
|
+
> in the web UI (under Analysis settings). `--council <handle>` selects one from
|
|
223
|
+
> the CLI; the handle can be the council's name, its name-slug, an id prefix, or
|
|
224
|
+
> a partial name fragment (which resolves when it uniquely identifies a council,
|
|
225
|
+
> otherwise lists the candidates). Run `pair-review --list-councils` to discover
|
|
226
|
+
> available handles.
|
|
227
|
+
|
|
216
228
|
## Configuration
|
|
217
229
|
|
|
218
230
|
On first run, pair-review will prompt you to configure the application.
|
|
@@ -395,6 +407,8 @@ You can override provider settings and define custom models in your config file.
|
|
|
395
407
|
| `extra_args` | Additional arguments to pass to the CLI |
|
|
396
408
|
| `env` | Environment variables to set when running the CLI |
|
|
397
409
|
| `installInstructions` | Custom installation instructions shown in UI |
|
|
410
|
+
| `availability_timeout_seconds` | Seconds to allow for the startup availability probe before the provider is reported unavailable (default `10`). Raise it for providers whose check runs a slow build/compile step. Also supported per chat provider under `chat_providers.<id>`. |
|
|
411
|
+
| `availability_command` | Command run to decide availability. Executable providers default to always-available when omitted; chat providers fall back to `<command> --version` (or, for the built-in Pi, the cached AI-provider status). Pair with `availability_timeout_seconds` when the probe runs a slow build. |
|
|
398
412
|
| `models` | Array of model definitions (see below) |
|
|
399
413
|
|
|
400
414
|
#### Model Configuration Fields
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.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/js/local.js
CHANGED
package/public/js/pr.js
CHANGED
|
@@ -638,6 +638,44 @@ class PRManager {
|
|
|
638
638
|
const defaultTab = repoSettings?.default_tab || 'single';
|
|
639
639
|
const councilId = repoSettings?.default_council_id || reviewSettings?.last_council_id || null;
|
|
640
640
|
|
|
641
|
+
// A `?council=<id>` URL param (set by the CLI when opening the browser) takes
|
|
642
|
+
// highest priority for council selection. When present we force the council
|
|
643
|
+
// branch regardless of default_tab/settings, and derive configType from the
|
|
644
|
+
// council's own type ('council' or 'advanced') rather than the repo default.
|
|
645
|
+
const urlSearch = (typeof window !== 'undefined' && window.location && window.location.search) || '';
|
|
646
|
+
const urlCouncilId = new URLSearchParams(urlSearch).get('council');
|
|
647
|
+
if (urlCouncilId) {
|
|
648
|
+
let councilConfig = null;
|
|
649
|
+
let councilName = null;
|
|
650
|
+
let councilType = null;
|
|
651
|
+
try {
|
|
652
|
+
const resp = await fetch(`/api/councils/${urlCouncilId}`);
|
|
653
|
+
if (resp.ok) {
|
|
654
|
+
const data = await resp.json();
|
|
655
|
+
councilConfig = data.council?.config || null;
|
|
656
|
+
councilName = data.council?.name || null;
|
|
657
|
+
councilType = data.council?.type || null;
|
|
658
|
+
} else {
|
|
659
|
+
console.warn(`Failed to fetch council "${urlCouncilId}" from URL param (status ${resp.status}); falling back to default analysis config`);
|
|
660
|
+
}
|
|
661
|
+
} catch (e) {
|
|
662
|
+
console.warn('Failed to fetch council config for URL council param:', e);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Only honor the URL council if we successfully fetched its config.
|
|
666
|
+
// Otherwise fall through to the existing default-selection logic.
|
|
667
|
+
if (councilConfig) {
|
|
668
|
+
return {
|
|
669
|
+
isCouncil: true,
|
|
670
|
+
councilId: urlCouncilId,
|
|
671
|
+
councilConfig,
|
|
672
|
+
councilName,
|
|
673
|
+
configType: councilType || 'advanced',
|
|
674
|
+
customInstructions: null
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
641
679
|
if ((defaultTab === 'council' || defaultTab === 'advanced') && councilId) {
|
|
642
680
|
// Fetch the full council config so the progress modal can render correctly
|
|
643
681
|
let councilConfig = null;
|
|
@@ -763,6 +801,7 @@ class PRManager {
|
|
|
763
801
|
const cleanUrl = new URL(window.location);
|
|
764
802
|
cleanUrl.searchParams.delete('analyze');
|
|
765
803
|
cleanUrl.searchParams.delete('analysisConfigId');
|
|
804
|
+
cleanUrl.searchParams.delete('council');
|
|
766
805
|
history.replaceState(null, '', cleanUrl);
|
|
767
806
|
}
|
|
768
807
|
}
|
|
@@ -3706,7 +3745,13 @@ class PRManager {
|
|
|
3706
3745
|
// /file-content fetches) entirely when this body has none. The selector
|
|
3707
3746
|
// mirrors the one validatePendingEofGaps scans for.
|
|
3708
3747
|
if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
|
|
3709
|
-
|
|
3748
|
+
// Keep the in-flight promise on the entry. Until it resolves, the
|
|
3749
|
+
// trailing EOF gap still carries EOF_SENTINEL coords, which makes
|
|
3750
|
+
// findMatchingGap()'s overlap test unmatchable for a real target line.
|
|
3751
|
+
// Line-anchoring callers (expandForSuggestion) await this so a
|
|
3752
|
+
// suggestion/comment on a trailing unchanged line doesn't silently fail
|
|
3753
|
+
// to expand. Fire-and-forget for everyone else.
|
|
3754
|
+
entry.eofValidationPromise = this.validatePendingEofGaps(entry.fileBody);
|
|
3710
3755
|
}
|
|
3711
3756
|
}
|
|
3712
3757
|
|
|
@@ -4626,6 +4671,26 @@ class PRManager {
|
|
|
4626
4671
|
// lazy body has rendered. Without this the gap query below returns nothing.
|
|
4627
4672
|
await this.ensureFileBodyRendered(file);
|
|
4628
4673
|
|
|
4674
|
+
// The trailing end-of-file gap is created with EOF_SENTINEL (-1) coords and
|
|
4675
|
+
// resolved to real line numbers asynchronously by validatePendingEofGaps(),
|
|
4676
|
+
// which _renderFileBodyNow fires fire-and-forget as the body renders. Until
|
|
4677
|
+
// it settles the gap's NEW/OLD end is negative, so findMatchingGap()'s
|
|
4678
|
+
// overlap test can never match a real target line — a suggestion (or
|
|
4679
|
+
// comment, via ensureLinesVisible) on a trailing unchanged line silently
|
|
4680
|
+
// fails to expand and never anchors. Pre-lazy-render this validation ran at
|
|
4681
|
+
// renderDiff time, long before any suggestion was placed; lazy rendering
|
|
4682
|
+
// collapsed that head start to nothing, which is the regression. Await the
|
|
4683
|
+
// same in-flight promise (not a second /file-content fetch) so the EOF gap
|
|
4684
|
+
// carries real coordinates before we match below.
|
|
4685
|
+
const lazyEntry = this._lazyFileBodies?.get(fileElement.dataset?.fileName || file);
|
|
4686
|
+
if (lazyEntry?.eofValidationPromise) {
|
|
4687
|
+
try {
|
|
4688
|
+
await lazyEntry.eofValidationPromise;
|
|
4689
|
+
} catch {
|
|
4690
|
+
// Validation removes the gap on fetch failure; matching simply misses.
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4629
4694
|
// Check if file is collapsed (generated files)
|
|
4630
4695
|
if (fileElement.classList.contains('collapsed')) {
|
|
4631
4696
|
debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
|
|
@@ -7069,22 +7134,42 @@ class PRManager {
|
|
|
7069
7134
|
}
|
|
7070
7135
|
|
|
7071
7136
|
/**
|
|
7072
|
-
*
|
|
7073
|
-
*
|
|
7074
|
-
*
|
|
7075
|
-
*
|
|
7137
|
+
* Build the worktree-not-found recovery URL. When the user arrived via
|
|
7138
|
+
* auto-analyze (?analyze=true), the reload link preserves the auto-analyze
|
|
7139
|
+
* state so analysis re-triggers after worktree setup. Both the
|
|
7140
|
+
* `analysisConfigId` and `council` params are carried through, since
|
|
7141
|
+
* `_buildDefaultAnalysisConfig()` treats `?council=<id>` as the
|
|
7142
|
+
* highest-priority analysis source — dropping it would silently fall back to
|
|
7143
|
+
* the repo/default analysis configuration on retry.
|
|
7076
7144
|
* @param {string} owner - Repository owner
|
|
7077
7145
|
* @param {string} repo - Repository name
|
|
7078
7146
|
* @param {number} number - PR number
|
|
7147
|
+
* @returns {string} The recovery URL (unescaped)
|
|
7079
7148
|
*/
|
|
7080
|
-
|
|
7149
|
+
_buildWorktreeRecoveryUrl(owner, repo, number) {
|
|
7081
7150
|
let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
|
|
7082
7151
|
if (this._autoAnalyzeRequested) {
|
|
7152
|
+
const currentParams = new URLSearchParams(window.location.search);
|
|
7083
7153
|
const params = new URLSearchParams({ analyze: 'true' });
|
|
7084
|
-
const analysisConfigId =
|
|
7154
|
+
const analysisConfigId = currentParams.get('analysisConfigId');
|
|
7155
|
+
const councilId = currentParams.get('council');
|
|
7085
7156
|
if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
|
|
7157
|
+
if (councilId) params.set('council', councilId);
|
|
7086
7158
|
setupUrl += `?${params.toString()}`;
|
|
7087
7159
|
}
|
|
7160
|
+
return setupUrl;
|
|
7161
|
+
}
|
|
7162
|
+
|
|
7163
|
+
/**
|
|
7164
|
+
* Show an error when the worktree is not found during analysis.
|
|
7165
|
+
* Displays a helpful message with a reload link that preserves any
|
|
7166
|
+
* auto-analyze state (see _buildWorktreeRecoveryUrl).
|
|
7167
|
+
* @param {string} owner - Repository owner
|
|
7168
|
+
* @param {string} repo - Repository name
|
|
7169
|
+
* @param {number} number - PR number
|
|
7170
|
+
*/
|
|
7171
|
+
showWorktreeNotFoundError(owner, repo, number) {
|
|
7172
|
+
const setupUrl = this._buildWorktreeRecoveryUrl(owner, repo, number);
|
|
7088
7173
|
const container = document.getElementById('pr-container');
|
|
7089
7174
|
if (container) {
|
|
7090
7175
|
container.innerHTML = `
|
package/public/setup.html
CHANGED
|
@@ -770,6 +770,10 @@
|
|
|
770
770
|
var qs = new URLSearchParams(window.location.search);
|
|
771
771
|
if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
|
|
772
772
|
if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
|
|
773
|
+
// Forward the CLI-supplied council selection (PR & local cold-start
|
|
774
|
+
// and delegated paths route through here); the review page keys
|
|
775
|
+
// council auto-analysis on this param.
|
|
776
|
+
if (qs.get('council')) targetUrl.searchParams.set('council', qs.get('council'));
|
|
773
777
|
window.location.href = targetUrl.toString();
|
|
774
778
|
return;
|
|
775
779
|
}
|
|
@@ -831,6 +835,10 @@
|
|
|
831
835
|
var qs = new URLSearchParams(window.location.search);
|
|
832
836
|
if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
|
|
833
837
|
if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
|
|
838
|
+
// Forward the CLI-supplied council selection (PR & local cold-start
|
|
839
|
+
// and delegated paths route through here); the review page keys
|
|
840
|
+
// council auto-analysis on this param.
|
|
841
|
+
if (qs.get('council')) targetUrl.searchParams.set('council', qs.get('council'));
|
|
834
842
|
window.location.href = targetUrl.toString();
|
|
835
843
|
}
|
|
836
844
|
}, 400);
|
|
@@ -858,9 +858,10 @@ class ClaudeProvider extends AIProvider {
|
|
|
858
858
|
* Test if Claude CLI is available
|
|
859
859
|
* Uses fast `--version` check instead of running a prompt.
|
|
860
860
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
861
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe
|
|
861
862
|
* @returns {Promise<boolean>}
|
|
862
863
|
*/
|
|
863
|
-
async testAvailability() {
|
|
864
|
+
async testAvailability(timeoutMs = 10000) {
|
|
864
865
|
return new Promise((resolve) => {
|
|
865
866
|
// For availability test, we just need to check --version
|
|
866
867
|
// Use the already-resolved command from the constructor (this.claudeCmd)
|
|
@@ -889,10 +890,10 @@ class ClaudeProvider extends AIProvider {
|
|
|
889
890
|
const availabilityTimeout = setTimeout(() => {
|
|
890
891
|
if (settled) return;
|
|
891
892
|
settled = true;
|
|
892
|
-
logger.warn(
|
|
893
|
+
logger.warn(`Claude CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
893
894
|
try { claude.kill(); } catch { /* ignore */ }
|
|
894
895
|
resolve(false);
|
|
895
|
-
},
|
|
896
|
+
}, timeoutMs);
|
|
896
897
|
|
|
897
898
|
claude.stdout.on('data', (data) => {
|
|
898
899
|
stdout += data.toString();
|
package/src/ai/codex-provider.js
CHANGED
|
@@ -763,9 +763,10 @@ class CodexProvider extends AIProvider {
|
|
|
763
763
|
* Test if Codex CLI is available
|
|
764
764
|
* Uses fast `--version` check instead of running a prompt.
|
|
765
765
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
766
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe
|
|
766
767
|
* @returns {Promise<boolean>}
|
|
767
768
|
*/
|
|
768
|
-
async testAvailability() {
|
|
769
|
+
async testAvailability(timeoutMs = 10000) {
|
|
769
770
|
return new Promise((resolve) => {
|
|
770
771
|
// For availability test, we just need to check --version
|
|
771
772
|
// Use the already-resolved command from the constructor (this.codexCmd)
|
|
@@ -794,10 +795,10 @@ class CodexProvider extends AIProvider {
|
|
|
794
795
|
const availabilityTimeout = setTimeout(() => {
|
|
795
796
|
if (settled) return;
|
|
796
797
|
settled = true;
|
|
797
|
-
logger.warn(
|
|
798
|
+
logger.warn(`Codex CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
798
799
|
try { codex.kill(); } catch { /* ignore */ }
|
|
799
800
|
resolve(false);
|
|
800
|
-
},
|
|
801
|
+
}, timeoutMs);
|
|
801
802
|
|
|
802
803
|
codex.stdout.on('data', (data) => {
|
|
803
804
|
stdout += data.toString();
|
|
@@ -439,9 +439,11 @@ class CopilotProvider extends AIProvider {
|
|
|
439
439
|
/**
|
|
440
440
|
* Test if Copilot CLI is available
|
|
441
441
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
442
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe.
|
|
443
|
+
* Production passes the per-provider resolved value; the default is only hit by tests.
|
|
442
444
|
* @returns {Promise<boolean>}
|
|
443
445
|
*/
|
|
444
|
-
async testAvailability() {
|
|
446
|
+
async testAvailability(timeoutMs = 10000) {
|
|
445
447
|
return new Promise((resolve) => {
|
|
446
448
|
// For availability test, check --version
|
|
447
449
|
// Use the already-resolved command from the constructor (this.copilotCmd)
|
|
@@ -465,6 +467,16 @@ class CopilotProvider extends AIProvider {
|
|
|
465
467
|
let stdout = '';
|
|
466
468
|
let settled = false;
|
|
467
469
|
|
|
470
|
+
// Timeout guard: if the CLI hangs, kill it and resolve false so the probe
|
|
471
|
+
// does not leak a child process.
|
|
472
|
+
const availabilityTimeout = setTimeout(() => {
|
|
473
|
+
if (settled) return;
|
|
474
|
+
settled = true;
|
|
475
|
+
logger.warn(`Copilot CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
476
|
+
try { copilot.kill(); } catch { /* ignore */ }
|
|
477
|
+
resolve(false);
|
|
478
|
+
}, timeoutMs);
|
|
479
|
+
|
|
468
480
|
copilot.stdout.on('data', (data) => {
|
|
469
481
|
stdout += data.toString();
|
|
470
482
|
});
|
|
@@ -472,6 +484,7 @@ class CopilotProvider extends AIProvider {
|
|
|
472
484
|
copilot.on('close', (code) => {
|
|
473
485
|
if (settled) return;
|
|
474
486
|
settled = true;
|
|
487
|
+
clearTimeout(availabilityTimeout);
|
|
475
488
|
// Copilot CLI typically outputs version info on success
|
|
476
489
|
if (code === 0) {
|
|
477
490
|
logger.info(`Copilot CLI available: ${stdout.trim()}`);
|
|
@@ -485,6 +498,7 @@ class CopilotProvider extends AIProvider {
|
|
|
485
498
|
copilot.on('error', (error) => {
|
|
486
499
|
if (settled) return;
|
|
487
500
|
settled = true;
|
|
501
|
+
clearTimeout(availabilityTimeout);
|
|
488
502
|
logger.warn(`Copilot CLI not available: ${error.message}`);
|
|
489
503
|
resolve(false);
|
|
490
504
|
});
|
|
@@ -783,9 +783,10 @@ class CursorAgentProvider extends AIProvider {
|
|
|
783
783
|
/**
|
|
784
784
|
* Test if Cursor Agent CLI is available
|
|
785
785
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
786
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe
|
|
786
787
|
* @returns {Promise<boolean>}
|
|
787
788
|
*/
|
|
788
|
-
async testAvailability() {
|
|
789
|
+
async testAvailability(timeoutMs = 10000) {
|
|
789
790
|
return new Promise((resolve) => {
|
|
790
791
|
// For availability test, we just need to check --version
|
|
791
792
|
// Use the already-resolved command from the constructor (this.agentCmd)
|
|
@@ -813,10 +814,10 @@ class CursorAgentProvider extends AIProvider {
|
|
|
813
814
|
const availabilityTimeout = setTimeout(() => {
|
|
814
815
|
if (settled) return;
|
|
815
816
|
settled = true;
|
|
816
|
-
logger.warn(
|
|
817
|
+
logger.warn(`Cursor Agent CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
817
818
|
try { agent.kill(); } catch { /* ignore */ }
|
|
818
819
|
resolve(false);
|
|
819
|
-
},
|
|
820
|
+
}, timeoutMs);
|
|
820
821
|
|
|
821
822
|
agent.stdout.on('data', (data) => {
|
|
822
823
|
stdout += data.toString();
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const providerModule = require('./provider');
|
|
15
|
-
const { AIProvider, resolveDefaultModel, inferModelDefaults } = providerModule;
|
|
15
|
+
const { AIProvider, resolveDefaultModel, inferModelDefaults, secondsToTimeoutMs } = providerModule;
|
|
16
16
|
const { spawn } = require('child_process');
|
|
17
17
|
const { glob } = require('glob');
|
|
18
18
|
const fs = require('fs').promises;
|
|
@@ -136,6 +136,10 @@ function createExecutableProviderClass(id, config) {
|
|
|
136
136
|
this.mappingInstructions = config.mapping_instructions || '';
|
|
137
137
|
this.timeout = config.timeout || 600000; // Default 10 minutes
|
|
138
138
|
this.availabilityCommand = config.availability_command || 'true';
|
|
139
|
+
// Availability-probe timeout. Configured in seconds (mirrors
|
|
140
|
+
// checkout_timeout_seconds); falls back to the shared default when
|
|
141
|
+
// unset/invalid.
|
|
142
|
+
this.availabilityTimeoutMs = secondsToTimeoutMs(config.availability_timeout_seconds);
|
|
139
143
|
this.extraEnv = {
|
|
140
144
|
...(config.env || {}),
|
|
141
145
|
...(configOverrides.env || {}),
|
|
@@ -454,9 +458,13 @@ function createExecutableProviderClass(id, config) {
|
|
|
454
458
|
* Test if the external tool is available.
|
|
455
459
|
* Runs the configured availability_command (defaults to 'true', i.e. always available).
|
|
456
460
|
*
|
|
461
|
+
* @param {number} [timeoutMs] - Timeout in milliseconds for the probe.
|
|
462
|
+
* Defaults to the provider's configured availability_timeout_seconds
|
|
463
|
+
* (or 10s). Tools with slow build-based availability commands can raise
|
|
464
|
+
* this via `availability_timeout_seconds` in their provider config.
|
|
457
465
|
* @returns {Promise<boolean>}
|
|
458
466
|
*/
|
|
459
|
-
async testAvailability() {
|
|
467
|
+
async testAvailability(timeoutMs = this.availabilityTimeoutMs) {
|
|
460
468
|
return new Promise((resolve) => {
|
|
461
469
|
const command = this.availabilityCommand;
|
|
462
470
|
logger.debug(`${id} availability check: ${command}`);
|
|
@@ -471,10 +479,10 @@ function createExecutableProviderClass(id, config) {
|
|
|
471
479
|
const availabilityTimeout = setTimeout(() => {
|
|
472
480
|
if (settled) return;
|
|
473
481
|
settled = true;
|
|
474
|
-
logger.warn(`${id} availability check timed out after
|
|
482
|
+
logger.warn(`${id} availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
475
483
|
try { child.kill(); } catch { /* ignore */ }
|
|
476
484
|
resolve(false);
|
|
477
|
-
},
|
|
485
|
+
}, timeoutMs);
|
|
478
486
|
|
|
479
487
|
child.on('close', (code) => {
|
|
480
488
|
if (settled) return;
|
|
@@ -659,9 +659,11 @@ class GeminiProvider extends AIProvider {
|
|
|
659
659
|
/**
|
|
660
660
|
* Test if Gemini CLI is available
|
|
661
661
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
662
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe.
|
|
663
|
+
* Production passes the per-provider resolved value; the default is only hit by tests.
|
|
662
664
|
* @returns {Promise<boolean>}
|
|
663
665
|
*/
|
|
664
|
-
async testAvailability() {
|
|
666
|
+
async testAvailability(timeoutMs = 10000) {
|
|
665
667
|
return new Promise((resolve) => {
|
|
666
668
|
// For availability test, we just need to check --version
|
|
667
669
|
// Use the already-resolved command from the constructor (this.geminiCmd)
|
|
@@ -685,6 +687,16 @@ class GeminiProvider extends AIProvider {
|
|
|
685
687
|
let stdout = '';
|
|
686
688
|
let settled = false;
|
|
687
689
|
|
|
690
|
+
// Timeout guard: if the CLI hangs, kill it and resolve false so the probe
|
|
691
|
+
// does not leak a child process.
|
|
692
|
+
const availabilityTimeout = setTimeout(() => {
|
|
693
|
+
if (settled) return;
|
|
694
|
+
settled = true;
|
|
695
|
+
logger.warn(`Gemini CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
696
|
+
try { gemini.kill(); } catch { /* ignore */ }
|
|
697
|
+
resolve(false);
|
|
698
|
+
}, timeoutMs);
|
|
699
|
+
|
|
688
700
|
gemini.stdout.on('data', (data) => {
|
|
689
701
|
stdout += data.toString();
|
|
690
702
|
});
|
|
@@ -692,6 +704,7 @@ class GeminiProvider extends AIProvider {
|
|
|
692
704
|
gemini.on('close', (code) => {
|
|
693
705
|
if (settled) return;
|
|
694
706
|
settled = true;
|
|
707
|
+
clearTimeout(availabilityTimeout);
|
|
695
708
|
if (code === 0 && stdout.includes('.')) {
|
|
696
709
|
logger.info(`Gemini CLI available: ${stdout.trim()}`);
|
|
697
710
|
resolve(true);
|
|
@@ -704,6 +717,7 @@ class GeminiProvider extends AIProvider {
|
|
|
704
717
|
gemini.on('error', (error) => {
|
|
705
718
|
if (settled) return;
|
|
706
719
|
settled = true;
|
|
720
|
+
clearTimeout(availabilityTimeout);
|
|
707
721
|
logger.warn(`Gemini CLI not available: ${error.message}`);
|
|
708
722
|
resolve(false);
|
|
709
723
|
});
|
package/src/ai/index.js
CHANGED
|
@@ -17,6 +17,9 @@ const {
|
|
|
17
17
|
getAllProvidersInfo,
|
|
18
18
|
createProvider,
|
|
19
19
|
testProviderAvailability,
|
|
20
|
+
resolveAvailabilityTimeoutMs,
|
|
21
|
+
secondsToTimeoutMs,
|
|
22
|
+
DEFAULT_AVAILABILITY_TIMEOUT_MS,
|
|
20
23
|
applyConfigOverrides,
|
|
21
24
|
getProviderConfigOverrides,
|
|
22
25
|
inferModelDefaults,
|
|
@@ -69,6 +72,9 @@ module.exports = {
|
|
|
69
72
|
|
|
70
73
|
// Utilities
|
|
71
74
|
testProviderAvailability,
|
|
75
|
+
resolveAvailabilityTimeoutMs,
|
|
76
|
+
secondsToTimeoutMs,
|
|
77
|
+
DEFAULT_AVAILABILITY_TIMEOUT_MS,
|
|
72
78
|
|
|
73
79
|
// Config override support
|
|
74
80
|
applyConfigOverrides,
|
|
@@ -587,9 +587,11 @@ class OpenCodeProvider extends AIProvider {
|
|
|
587
587
|
/**
|
|
588
588
|
* Test if OpenCode CLI is available
|
|
589
589
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
590
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe.
|
|
591
|
+
* Production passes the per-provider resolved value; the default is only hit by tests.
|
|
590
592
|
* @returns {Promise<boolean>}
|
|
591
593
|
*/
|
|
592
|
-
async testAvailability() {
|
|
594
|
+
async testAvailability(timeoutMs = 10000) {
|
|
593
595
|
return new Promise((resolve) => {
|
|
594
596
|
// For availability test, we just need to check --version
|
|
595
597
|
// Use the already-resolved command from the constructor (this.opencodeCmd)
|
|
@@ -613,6 +615,16 @@ class OpenCodeProvider extends AIProvider {
|
|
|
613
615
|
let stdout = '';
|
|
614
616
|
let settled = false;
|
|
615
617
|
|
|
618
|
+
// Timeout guard: if the CLI hangs, kill it and resolve false so the probe
|
|
619
|
+
// does not leak a child process.
|
|
620
|
+
const availabilityTimeout = setTimeout(() => {
|
|
621
|
+
if (settled) return;
|
|
622
|
+
settled = true;
|
|
623
|
+
logger.warn(`OpenCode CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
624
|
+
try { opencode.kill(); } catch { /* ignore */ }
|
|
625
|
+
resolve(false);
|
|
626
|
+
}, timeoutMs);
|
|
627
|
+
|
|
616
628
|
opencode.stdout.on('data', (data) => {
|
|
617
629
|
stdout += data.toString();
|
|
618
630
|
});
|
|
@@ -620,6 +632,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
620
632
|
opencode.on('close', (code) => {
|
|
621
633
|
if (settled) return;
|
|
622
634
|
settled = true;
|
|
635
|
+
clearTimeout(availabilityTimeout);
|
|
623
636
|
if (code === 0) {
|
|
624
637
|
logger.info(`OpenCode CLI available: ${stdout.trim()}`);
|
|
625
638
|
resolve(true);
|
|
@@ -632,6 +645,7 @@ class OpenCodeProvider extends AIProvider {
|
|
|
632
645
|
opencode.on('error', (error) => {
|
|
633
646
|
if (settled) return;
|
|
634
647
|
settled = true;
|
|
648
|
+
clearTimeout(availabilityTimeout);
|
|
635
649
|
logger.warn(`OpenCode CLI not available: ${error.message}`);
|
|
636
650
|
resolve(false);
|
|
637
651
|
});
|
package/src/ai/pi-provider.js
CHANGED
|
@@ -1103,9 +1103,10 @@ class PiProvider extends AIProvider {
|
|
|
1103
1103
|
/**
|
|
1104
1104
|
* Test if Pi CLI is available
|
|
1105
1105
|
* Uses the command configured in the instance (respects ENV > config > default precedence)
|
|
1106
|
+
* @param {number} [timeoutMs=10000] - Timeout in milliseconds for the probe
|
|
1106
1107
|
* @returns {Promise<boolean>}
|
|
1107
1108
|
*/
|
|
1108
|
-
async testAvailability() {
|
|
1109
|
+
async testAvailability(timeoutMs = 10000) {
|
|
1109
1110
|
return new Promise((resolve) => {
|
|
1110
1111
|
// For availability test, we just need to check --version
|
|
1111
1112
|
// Use the already-resolved command from the constructor (this.piCmd)
|
|
@@ -1135,10 +1136,10 @@ class PiProvider extends AIProvider {
|
|
|
1135
1136
|
const availabilityTimeout = setTimeout(() => {
|
|
1136
1137
|
if (settled) return;
|
|
1137
1138
|
settled = true;
|
|
1138
|
-
logger.warn(`${name} CLI availability check timed out after
|
|
1139
|
+
logger.warn(`${name} CLI availability check timed out after ${Math.round(timeoutMs / 1000)}s`);
|
|
1139
1140
|
try { pi.kill(); } catch { /* ignore */ }
|
|
1140
1141
|
resolve(false);
|
|
1141
|
-
},
|
|
1142
|
+
}, timeoutMs);
|
|
1142
1143
|
|
|
1143
1144
|
pi.stdout.on('data', (data) => {
|
|
1144
1145
|
stdout += data.toString();
|