@in-the-loop-labs/pair-review 3.7.1 → 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 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": "@in-the-loop-labs/pair-review",
3
- "version": "3.7.1",
3
+ "version": "3.8.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": "3.7.1",
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.7.1",
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",
@@ -86,6 +86,7 @@ class LocalManager {
86
86
  } finally {
87
87
  const cleanUrl = new URL(window.location);
88
88
  cleanUrl.searchParams.delete('analyze');
89
+ cleanUrl.searchParams.delete('council');
89
90
  history.replaceState(null, '', cleanUrl);
90
91
  }
91
92
  }
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
  }
@@ -1690,8 +1729,12 @@ class PRManager {
1690
1729
  label = 'Exit guided tour';
1691
1730
  } else if (this._tourStops && this._tourStops.length > 0) {
1692
1731
  label = 'Start guided tour';
1732
+ } else if (this._toursAutoGenerate === false) {
1733
+ // No stops yet and auto-generation is off: a click kicks off manual
1734
+ // generation (see startOrToggleTour), so the verb is "Generate".
1735
+ label = 'Generate guided tour';
1693
1736
  } else {
1694
- label = 'Start guided tour (none available yet)';
1737
+ label = 'Guided tour (none available yet)';
1695
1738
  }
1696
1739
  btn.title = label;
1697
1740
  btn.setAttribute('aria-label', label);
@@ -3702,7 +3745,13 @@ class PRManager {
3702
3745
  // /file-content fetches) entirely when this body has none. The selector
3703
3746
  // mirrors the one validatePendingEofGaps scans for.
3704
3747
  if (entry.fileBody.querySelector('tr.context-expand-row[data-pending-eof-validation="true"]')) {
3705
- this.validatePendingEofGaps(entry.fileBody);
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);
3706
3755
  }
3707
3756
  }
3708
3757
 
@@ -4622,6 +4671,26 @@ class PRManager {
4622
4671
  // lazy body has rendered. Without this the gap query below returns nothing.
4623
4672
  await this.ensureFileBodyRendered(file);
4624
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
+
4625
4694
  // Check if file is collapsed (generated files)
4626
4695
  if (fileElement.classList.contains('collapsed')) {
4627
4696
  debugLog?.('expandForSuggestion', 'File is collapsed, expanding first');
@@ -7065,22 +7134,42 @@ class PRManager {
7065
7134
  }
7066
7135
 
7067
7136
  /**
7068
- * Show an error when the worktree is not found during analysis.
7069
- * Displays a helpful message with a reload link. If the user arrived
7070
- * via auto-analyze (?analyze=true), the reload link preserves that
7071
- * parameter so analysis re-triggers after setup.
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.
7072
7144
  * @param {string} owner - Repository owner
7073
7145
  * @param {string} repo - Repository name
7074
7146
  * @param {number} number - PR number
7147
+ * @returns {string} The recovery URL (unescaped)
7075
7148
  */
7076
- showWorktreeNotFoundError(owner, repo, number) {
7149
+ _buildWorktreeRecoveryUrl(owner, repo, number) {
7077
7150
  let setupUrl = `/pr/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(number)}`;
7078
7151
  if (this._autoAnalyzeRequested) {
7152
+ const currentParams = new URLSearchParams(window.location.search);
7079
7153
  const params = new URLSearchParams({ analyze: 'true' });
7080
- const analysisConfigId = new URLSearchParams(window.location.search).get('analysisConfigId');
7154
+ const analysisConfigId = currentParams.get('analysisConfigId');
7155
+ const councilId = currentParams.get('council');
7081
7156
  if (analysisConfigId) params.set('analysisConfigId', analysisConfigId);
7157
+ if (councilId) params.set('council', councilId);
7082
7158
  setupUrl += `?${params.toString()}`;
7083
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);
7084
7173
  const container = document.getElementById('pr-container');
7085
7174
  if (container) {
7086
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('Claude CLI availability check timed out after 10s');
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
- }, 10000);
896
+ }, timeoutMs);
896
897
 
897
898
  claude.stdout.on('data', (data) => {
898
899
  stdout += data.toString();
@@ -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('Codex CLI availability check timed out after 10s');
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
- }, 10000);
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('Cursor Agent CLI availability check timed out after 10s');
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
- }, 10000);
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 10s`);
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
- }, 10000);
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
  });
@@ -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 10s`);
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
- }, 10000);
1142
+ }, timeoutMs);
1142
1143
 
1143
1144
  pi.stdout.on('data', (data) => {
1144
1145
  stdout += data.toString();