@link-assistant/hive-mind 1.20.0 → 1.21.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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6cf54b7: Add configurable queue threshold strategies (reject, enqueue, dequeue-one-at-a-time)
8
+ - Add three handling strategies for each queue threshold:
9
+ - `reject`: Immediately reject the command, no queueing
10
+ - `enqueue`: Block and wait in queue until metric drops
11
+ - `dequeue-one-at-a-time`: Allow one command, block subsequent
12
+ - Support configuration via `HIVE_MIND_QUEUE_CONFIG` environment variable (links notation format)
13
+ - Support individual strategy env vars (e.g., `HIVE_MIND_DISK_STRATEGY`)
14
+
15
+ **Breaking change:** Disk threshold default strategy changed from `dequeue-one-at-a-time` to `reject`
16
+ because the queue is lost on server restart. To restore old behavior: `HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time`
17
+
18
+ ## 1.20.1
19
+
20
+ ### Patch Changes
21
+
22
+ - 1689caf: Fix agent tool pricing display to show correct provider
23
+ - Add proper model mapping for free models (kimi-k2.5-free, gpt-4o-mini, etc.)
24
+ - Add getProviderName helper function to detect provider from model ID
25
+ - Prioritize provider from model ID over API response to fix issue #1250
26
+ - Display correct provider names: Moonshot AI, OpenAI, Anthropic instead of generic "OpenCode Zen"
27
+
3
28
  ## 1.20.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -481,9 +481,9 @@ Free Models (with --tool agent):
481
481
  /solve https://github.com/owner/repo/issues/123 --tool agent --model gpt-5-nano
482
482
  /solve https://github.com/owner/repo/issues/123 --tool agent --model glm-4.7-free
483
483
  /solve https://github.com/owner/repo/issues/123 --tool agent --model big-pickle
484
+ ```
484
485
 
485
486
  > **📖 Free Models Guide**: See [docs/FREE_MODELS.md](./docs/FREE_MODELS.md) for comprehensive information about all free models.
486
- ```
487
487
 
488
488
  #### `/hive` - Run Hive Orchestration
489
489
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/agent.lib.mjs CHANGED
@@ -79,17 +79,51 @@ export const parseAgentTokenUsage = output => {
79
79
  return usage;
80
80
  };
81
81
 
82
+ /**
83
+ * Helper function to get original provider name from provider identifier
84
+ * Used for calculating public pricing estimates based on original provider prices
85
+ * @param {string} providerId - Provider identifier (e.g., 'openai', 'anthropic', 'moonshot')
86
+ * @returns {string} Human-readable provider name for pricing reference
87
+ */
88
+ const getOriginalProviderName = providerId => {
89
+ if (!providerId) return null;
90
+
91
+ const providerMap = {
92
+ openai: 'OpenAI',
93
+ anthropic: 'Anthropic',
94
+ moonshot: 'Moonshot AI',
95
+ google: 'Google',
96
+ opencode: 'OpenCode Zen',
97
+ grok: 'xAI',
98
+ };
99
+
100
+ return providerMap[providerId] || providerId.charAt(0).toUpperCase() + providerId.slice(1);
101
+ };
102
+
82
103
  /**
83
104
  * Calculate pricing for agent tool usage using models.dev API
105
+ * Issue #1250: Shows actual provider (OpenCode Zen) and calculates public pricing estimate
106
+ * based on original provider prices (Moonshot AI, OpenAI, Anthropic, etc.)
107
+ *
84
108
  * @param {string} modelId - The model ID used (e.g., 'opencode/grok-code')
85
109
  * @param {Object} tokenUsage - Token usage data from parseAgentTokenUsage
86
- * @returns {Object} Pricing information
110
+ * @returns {Object} Pricing information with:
111
+ * - provider: Always "OpenCode Zen" (actual provider)
112
+ * - originalProvider: The original model provider for pricing reference
113
+ * - totalCostUSD: Public pricing estimate based on original provider prices
114
+ * - opencodeCost: Actual billed cost from OpenCode Zen (free for most models)
87
115
  */
88
116
  export const calculateAgentPricing = async (modelId, tokenUsage) => {
89
117
  // Extract the model name from provider/model format
90
118
  // e.g., 'opencode/grok-code' -> 'grok-code'
91
119
  const modelName = modelId.includes('/') ? modelId.split('/').pop() : modelId;
92
120
 
121
+ // Extract provider from model ID to determine original provider for pricing
122
+ const providerFromModel = modelId.includes('/') ? modelId.split('/')[0] : null;
123
+
124
+ // Get original provider name for pricing reference
125
+ const originalProvider = getOriginalProviderName(providerFromModel);
126
+
93
127
  try {
94
128
  // Fetch model info from models.dev API
95
129
  const modelInfo = await fetchModelInfo(modelName);
@@ -97,7 +131,7 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
97
131
  if (modelInfo && modelInfo.cost) {
98
132
  const cost = modelInfo.cost;
99
133
 
100
- // Calculate cost based on token usage
134
+ // Calculate public pricing estimate based on original provider prices
101
135
  // Prices are per 1M tokens, so divide by 1,000,000
102
136
  const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
103
137
  const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
@@ -106,10 +140,17 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
106
140
 
107
141
  const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
108
142
 
143
+ // Determine if this is a free model from OpenCode Zen
144
+ // Models accessed via OpenCode Zen are free, regardless of original provider pricing
145
+ const isOpencodeFreeModel = providerFromModel === 'opencode' || modelName.toLowerCase().includes('free') || modelName.toLowerCase().includes('grok') || providerFromModel === 'moonshot' || providerFromModel === 'openai' || providerFromModel === 'anthropic';
146
+
109
147
  return {
110
148
  modelId,
111
149
  modelName: modelInfo.name || modelName,
112
- provider: modelInfo.provider || 'OpenCode Zen',
150
+ // Issue #1250: Always show OpenCode Zen as actual provider
151
+ provider: 'OpenCode Zen',
152
+ // Store original provider for reference in pricing display
153
+ originalProvider: originalProvider || modelInfo.provider || null,
113
154
  pricing: {
114
155
  inputPerMillion: cost.input || 0,
115
156
  outputPerMillion: cost.output || 0,
@@ -123,18 +164,26 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
123
164
  cacheRead: cacheReadCost,
124
165
  cacheWrite: cacheWriteCost,
125
166
  },
167
+ // Public pricing estimate based on original provider prices
126
168
  totalCostUSD: totalCost,
169
+ // Actual cost from OpenCode Zen (free for supported models)
170
+ opencodeCost: isOpencodeFreeModel ? 0 : totalCost,
171
+ // Keep for backward compatibility - indicates if model has zero pricing
127
172
  isFreeModel: cost.input === 0 && cost.output === 0,
173
+ // New flag to indicate if OpenCode Zen provides this model for free
174
+ isOpencodeFreeModel,
128
175
  };
129
176
  }
130
-
131
177
  // Model not found in API, return what we have
132
178
  return {
133
179
  modelId,
134
180
  modelName,
135
- provider: 'Unknown',
181
+ provider: 'OpenCode Zen',
182
+ originalProvider,
136
183
  tokenUsage,
137
184
  totalCostUSD: null,
185
+ opencodeCost: 0, // OpenCode Zen is free
186
+ isOpencodeFreeModel: true,
138
187
  error: 'Model not found in models.dev API',
139
188
  };
140
189
  } catch (error) {
@@ -142,8 +191,12 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
142
191
  return {
143
192
  modelId,
144
193
  modelName,
194
+ provider: 'OpenCode Zen',
195
+ originalProvider,
145
196
  tokenUsage,
146
197
  totalCostUSD: null,
198
+ opencodeCost: 0, // OpenCode Zen is free
199
+ isOpencodeFreeModel: true,
147
200
  error: error.message,
148
201
  };
149
202
  }
@@ -163,6 +216,12 @@ export const mapModelToId = model => {
163
216
  haiku: 'anthropic/claude-3-5-haiku',
164
217
  opus: 'anthropic/claude-3-opus',
165
218
  'gemini-3-pro': 'google/gemini-3-pro',
219
+ // Free models mapping for issue #1250
220
+ 'kimi-k2.5-free': 'moonshot/kimi-k2.5-free',
221
+ 'gpt-4o-mini': 'openai/gpt-4o-mini',
222
+ 'gpt-4o': 'openai/gpt-4o',
223
+ 'claude-3.5-haiku': 'anthropic/claude-3.5-haiku',
224
+ 'claude-3.5-sonnet': 'anthropic/claude-3.5-sonnet',
166
225
  };
167
226
 
168
227
  // Return mapped model ID if it's an alias, otherwise return as-is
@@ -22,25 +22,50 @@ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
22
22
 
23
23
  /**
24
24
  * Build cost estimation string for log comments
25
+ * Issue #1250: Enhanced to show both public pricing estimate and actual provider cost
26
+ *
25
27
  * @param {number|null} totalCostUSD - Public pricing estimate
26
28
  * @param {number|null} anthropicTotalCostUSD - Cost calculated by Anthropic (Claude-specific)
27
29
  * @param {Object|null} pricingInfo - Pricing info from agent tool
30
+ * - opencodeCost: Actual billed cost from OpenCode Zen (for agent tool)
31
+ * - isOpencodeFreeModel: Whether OpenCode Zen provides this model for free
32
+ * - originalProvider: Original provider for pricing reference
28
33
  * @returns {string} Formatted cost info string for markdown (empty if no data available)
29
34
  */
30
35
  const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
31
36
  // Issue #1015: Don't show cost section when all values are unknown (clutters output)
32
37
  const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
33
38
  const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
34
- const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel);
35
- if (!hasPublic && !hasAnthropic && !hasPricing) return '';
39
+ const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
40
+ // Issue #1250: Check for OpenCode Zen actual cost
41
+ const hasOpencodeCost = pricingInfo?.opencodeCost !== null && pricingInfo?.opencodeCost !== undefined;
42
+ if (!hasPublic && !hasAnthropic && !hasPricing && !hasOpencodeCost) return '';
36
43
  let costInfo = '\n\n💰 **Cost estimation:**';
37
44
  if (pricingInfo?.modelName) {
38
45
  costInfo += `\n- Model: ${pricingInfo.modelName}`;
39
46
  if (pricingInfo.provider) costInfo += `\n- Provider: ${pricingInfo.provider}`;
40
47
  }
48
+ // Issue #1250: Show public pricing estimate based on original provider prices
41
49
  if (hasPublic) {
42
- costInfo += pricingInfo?.isFreeModel ? '\n- Public pricing estimate: $0.00 (Free model)' : `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)} USD`;
43
- } else if (hasPricing) costInfo += '\n- Public pricing estimate: unknown';
50
+ // For models with zero pricing from original provider, show as free
51
+ if (pricingInfo?.isFreeModel && totalCostUSD === 0) {
52
+ costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
53
+ } else {
54
+ // Show actual public pricing estimate with original provider reference
55
+ const originalProviderRef = pricingInfo?.originalProvider ? ` (based on ${pricingInfo.originalProvider} prices)` : '';
56
+ costInfo += `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)}${originalProviderRef}`;
57
+ }
58
+ } else if (hasPricing) {
59
+ costInfo += '\n- Public pricing estimate: unknown';
60
+ }
61
+ // Issue #1250: Show actual cost from OpenCode Zen for agent tool
62
+ if (hasOpencodeCost) {
63
+ if (pricingInfo.isOpencodeFreeModel) {
64
+ costInfo += '\n- Calculated by OpenCode Zen: $0.00 (Free model)';
65
+ } else {
66
+ costInfo += `\n- Calculated by OpenCode Zen: $${pricingInfo.opencodeCost.toFixed(6)}`;
67
+ }
68
+ }
44
69
  if (pricingInfo?.tokenUsage) {
45
70
  const u = pricingInfo.tokenUsage;
46
71
  let tokenInfo = `\n- Token usage: ${u.inputTokens?.toLocaleString() || 0} input, ${u.outputTokens?.toLocaleString() || 0} output`;
@@ -7,7 +7,20 @@
7
7
  * This module is used by both telegram-solve-queue.lib.mjs (queue logic)
8
8
  * and limits.lib.mjs (display formatting).
9
9
  *
10
+ * Supports three handling strategies per threshold:
11
+ * - 'reject': Immediately reject the command, no queueing
12
+ * - 'enqueue': Block and wait in queue until metric drops below threshold
13
+ * - 'dequeue-one-at-a-time': Allow exactly one command, block subsequent
14
+ *
15
+ * Configuration can be provided via:
16
+ * 1. HIVE_MIND_QUEUE_CONFIG environment variable (links notation format)
17
+ * 2. Individual environment variables (e.g., HIVE_MIND_DISK_THRESHOLD)
18
+ * 3. Built-in defaults
19
+ *
20
+ * Priority: HIVE_MIND_QUEUE_CONFIG > individual env vars > defaults
21
+ *
10
22
  * @see https://github.com/link-assistant/hive-mind/issues/1242
23
+ * @see https://github.com/link-assistant/hive-mind/issues/1253
11
24
  */
12
25
 
13
26
  // Use use-m to dynamically import modules
@@ -24,6 +37,137 @@ if (typeof globalThis.use === 'undefined') {
24
37
  }
25
38
 
26
39
  const getenv = await use('getenv');
40
+ const linoModule = await use('links-notation');
41
+ const LinoParser = linoModule.Parser || linoModule.default?.Parser;
42
+
43
+ /**
44
+ * Valid threshold handling strategies
45
+ * @type {readonly ['reject', 'enqueue', 'dequeue-one-at-a-time']}
46
+ */
47
+ export const THRESHOLD_STRATEGIES = Object.freeze(['reject', 'enqueue', 'dequeue-one-at-a-time']);
48
+
49
+ /**
50
+ * Validate a threshold strategy value
51
+ * @param {string} strategy - The strategy to validate
52
+ * @param {string} defaultStrategy - Default strategy if invalid
53
+ * @returns {string} Valid strategy
54
+ */
55
+ function validateStrategy(strategy, defaultStrategy = 'enqueue') {
56
+ if (!strategy || !THRESHOLD_STRATEGIES.includes(strategy)) {
57
+ return defaultStrategy;
58
+ }
59
+ return strategy;
60
+ }
61
+
62
+ /**
63
+ * Normalize metric name from links notation format to camelCase
64
+ * Examples: 'disk' -> 'disk', 'ram' -> 'ram', 'claude-5-hour' -> 'claude5Hour'
65
+ * @param {string} name - Metric name in kebab-case
66
+ * @returns {string} Metric name in normalized form
67
+ */
68
+ function normalizeMetricName(name) {
69
+ if (!name) return '';
70
+ return name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
71
+ }
72
+
73
+ /**
74
+ * Parse queue configuration from links notation
75
+ *
76
+ * Format:
77
+ * ```
78
+ * (
79
+ * (disk (90% reject))
80
+ * (ram (65% enqueue))
81
+ * (cpu (65% enqueue))
82
+ * (claude-5-hour (65% dequeue-one-at-a-time))
83
+ * (claude-weekly (97% dequeue-one-at-a-time))
84
+ * (github-api (75% enqueue))
85
+ * )
86
+ * ```
87
+ *
88
+ * @param {string} linoConfig - Configuration in links notation format
89
+ * @returns {Object} Parsed threshold configurations { [metricName]: { value: number, strategy: string } }
90
+ */
91
+ export function parseQueueConfig(linoConfig) {
92
+ if (!linoConfig || typeof linoConfig !== 'string') return {};
93
+
94
+ try {
95
+ const parser = new LinoParser();
96
+ const parsed = parser.parse(linoConfig);
97
+
98
+ if (!parsed || !Array.isArray(parsed) || parsed.length === 0) return {};
99
+
100
+ const config = {};
101
+
102
+ // The parser returns: [{ id: null, values: [...] }]
103
+ // We need to drill down to find the metric configurations
104
+ const topLink = parsed[0];
105
+ if (!topLink || !topLink.values) return {};
106
+
107
+ // Helper to extract all ids from a values array recursively
108
+ function extractIds(values) {
109
+ const ids = [];
110
+ if (!values) return ids;
111
+ for (const v of values) {
112
+ if (v.id) ids.push(v.id);
113
+ if (v.values && v.values.length > 0) {
114
+ ids.push(...extractIds(v.values));
115
+ }
116
+ }
117
+ return ids;
118
+ }
119
+
120
+ // Process each item in top-level values
121
+ // Structure can be:
122
+ // - Nested: [{ id: null, values: [{ id: 'disk', ... }, { id: null, values: [{ id: '90%' }, { id: 'reject' }] }] }]
123
+ // - Flat: [{ id: 'disk', ... }, { id: '90%', ... }, { id: 'reject', ... }]
124
+ for (const item of topLink.values) {
125
+ // Check if this is a nested config item (no id at this level)
126
+ if (item.id === null && item.values && item.values.length > 0) {
127
+ // Extract all IDs from this nested structure
128
+ const ids = extractIds(item.values);
129
+
130
+ // Find metric name, percentage, and strategy
131
+ let metricName = null;
132
+ let thresholdValue = null;
133
+ let strategy = null;
134
+
135
+ for (const id of ids) {
136
+ // Check for percentage
137
+ const percentMatch = id.match(/^(\d+)%$/);
138
+ if (percentMatch) {
139
+ thresholdValue = parseInt(percentMatch[1], 10) / 100;
140
+ continue;
141
+ }
142
+
143
+ // Check for strategy
144
+ if (THRESHOLD_STRATEGIES.includes(id)) {
145
+ strategy = id;
146
+ continue;
147
+ }
148
+
149
+ // Otherwise it's likely the metric name
150
+ if (!metricName) {
151
+ metricName = id;
152
+ }
153
+ }
154
+
155
+ if (metricName && thresholdValue !== null) {
156
+ const normalized = normalizeMetricName(metricName);
157
+ config[normalized] = {
158
+ value: thresholdValue,
159
+ strategy: validateStrategy(strategy),
160
+ };
161
+ }
162
+ }
163
+ }
164
+
165
+ return config;
166
+ } catch (error) {
167
+ console.error('[queue-config] Failed to parse HIVE_MIND_QUEUE_CONFIG:', error.message);
168
+ return {};
169
+ }
170
+ }
27
171
 
28
172
  // Helper function to safely parse floats with fallback
29
173
  const parseFloatWithDefault = (envVar, defaultValue) => {
@@ -39,6 +183,38 @@ const parseIntWithDefault = (envVar, defaultValue) => {
39
183
  return isNaN(parsed) ? defaultValue : parsed;
40
184
  };
41
185
 
186
+ // Parse links notation config from environment variable (if provided)
187
+ const linoConfig = parseQueueConfig(getenv('HIVE_MIND_QUEUE_CONFIG', ''));
188
+
189
+ /**
190
+ * Get threshold configuration with priority:
191
+ * 1. HIVE_MIND_QUEUE_CONFIG (links notation) - highest priority
192
+ * 2. Individual environment variables
193
+ * 3. Default values
194
+ *
195
+ * @param {string} linoKey - Key in normalized format for lino config (e.g., 'disk', 'ram')
196
+ * @param {string} envVarThreshold - Environment variable for threshold value
197
+ * @param {string} envVarStrategy - Environment variable for strategy
198
+ * @param {number} defaultThreshold - Default threshold value (0.0 - 1.0)
199
+ * @param {string} defaultStrategy - Default strategy
200
+ * @returns {{ value: number, strategy: string }}
201
+ */
202
+ function getThresholdConfig(linoKey, envVarThreshold, envVarStrategy, defaultThreshold, defaultStrategy) {
203
+ // Check links notation config first
204
+ if (linoConfig[linoKey]) {
205
+ return {
206
+ value: linoConfig[linoKey].value,
207
+ strategy: linoConfig[linoKey].strategy,
208
+ };
209
+ }
210
+
211
+ // Fall back to individual env vars, then defaults
212
+ return {
213
+ value: parseFloatWithDefault(envVarThreshold, defaultThreshold),
214
+ strategy: validateStrategy(getenv(envVarStrategy, ''), defaultStrategy),
215
+ };
216
+ }
217
+
42
218
  /**
43
219
  * Configuration constants for queue throttling
44
220
  * All thresholds use ratios (0.0 - 1.0) representing usage percentage
@@ -46,21 +222,39 @@ const parseIntWithDefault = (envVar, defaultValue) => {
46
222
  * IMPORTANT: Running claude processes is NOT a blocking limit by itself.
47
223
  * Commands can run in parallel as long as actual limits (CPU, API, etc.) are not exceeded.
48
224
  * See: https://github.com/link-assistant/hive-mind/issues/1078
225
+ *
226
+ * NEW in issue #1253: Each threshold now has a configurable strategy:
227
+ * - 'reject': Immediately reject the command (no queueing)
228
+ * - 'enqueue': Block and wait in queue
229
+ * - 'dequeue-one-at-a-time': Allow one command, block subsequent
230
+ *
231
+ * BREAKING CHANGE: Disk threshold default strategy changed from 'dequeue-one-at-a-time' to 'reject'
232
+ * because the queue is lost on server restart anyway, so there's no point in queueing.
233
+ * To restore old behavior: HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time
49
234
  */
50
235
  export const QUEUE_CONFIG = {
51
- // Resource thresholds (usage ratios: 0.0 - 1.0)
52
- // All thresholds use >= comparison (inclusive)
53
- RAM_THRESHOLD: parseFloatWithDefault('HIVE_MIND_RAM_THRESHOLD', 0.65), // Enqueue if RAM usage >= 65%
54
- // CPU threshold uses 5-minute load average, not instantaneous CPU usage
55
- CPU_THRESHOLD: parseFloatWithDefault('HIVE_MIND_CPU_THRESHOLD', 0.65), // Enqueue if 5-minute load average >= 65% of CPU count
56
- DISK_THRESHOLD: parseFloatWithDefault('HIVE_MIND_DISK_THRESHOLD', 0.9), // One-at-a-time if disk usage >= 90%, tuned for VM with 100 GB drive
57
-
58
- // API limit thresholds (usage ratios: 0.0 - 1.0)
59
- // All thresholds use >= comparison (inclusive)
60
- // Fine-tuned for Claude MAX $200 subscription
61
- CLAUDE_5_HOUR_SESSION_THRESHOLD: parseFloatWithDefault('HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 0.65), // One-at-a-time if 5-hour limit >= 65%
62
- CLAUDE_WEEKLY_THRESHOLD: parseFloatWithDefault('HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 0.97), // One-at-a-time if weekly limit >= 97%
63
- GITHUB_API_THRESHOLD: parseFloatWithDefault('HIVE_MIND_GITHUB_API_THRESHOLD', 0.75), // Enqueue if GitHub >= 75% with parallel claude
236
+ // Threshold configurations with value and strategy
237
+ // Priority: HIVE_MIND_QUEUE_CONFIG > individual env vars > defaults
238
+ thresholds: {
239
+ ram: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue'),
240
+ cpu: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue'),
241
+ // BREAKING: disk default changed from 'dequeue-one-at-a-time' to 'reject'
242
+ // Queue is in RAM and lost on restart - no point enlarging it when disk is full
243
+ // See: https://github.com/link-assistant/hive-mind/issues/1253
244
+ disk: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject'),
245
+ claude5Hour: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time'),
246
+ claudeWeekly: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time'),
247
+ githubApi: getThresholdConfig('githubApi', 'HIVE_MIND_GITHUB_API_THRESHOLD', 'HIVE_MIND_GITHUB_API_STRATEGY', 0.75, 'enqueue'),
248
+ },
249
+
250
+ // Legacy flat threshold values for backward compatibility
251
+ // These are derived from thresholds.{metric}.value
252
+ RAM_THRESHOLD: getThresholdConfig('ram', 'HIVE_MIND_RAM_THRESHOLD', 'HIVE_MIND_RAM_STRATEGY', 0.65, 'enqueue').value,
253
+ CPU_THRESHOLD: getThresholdConfig('cpu', 'HIVE_MIND_CPU_THRESHOLD', 'HIVE_MIND_CPU_STRATEGY', 0.65, 'enqueue').value,
254
+ DISK_THRESHOLD: getThresholdConfig('disk', 'HIVE_MIND_DISK_THRESHOLD', 'HIVE_MIND_DISK_STRATEGY', 0.9, 'reject').value,
255
+ CLAUDE_5_HOUR_SESSION_THRESHOLD: getThresholdConfig('claude5Hour', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_THRESHOLD', 'HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY', 0.65, 'dequeue-one-at-a-time').value,
256
+ CLAUDE_WEEKLY_THRESHOLD: getThresholdConfig('claudeWeekly', 'HIVE_MIND_CLAUDE_WEEKLY_THRESHOLD', 'HIVE_MIND_CLAUDE_WEEKLY_STRATEGY', 0.97, 'dequeue-one-at-a-time').value,
257
+ GITHUB_API_THRESHOLD: getThresholdConfig('githubApi', 'HIVE_MIND_GITHUB_API_THRESHOLD', 'HIVE_MIND_GITHUB_API_STRATEGY', 0.75, 'enqueue').value,
64
258
 
65
259
  // Timing
66
260
  // MIN_START_INTERVAL_MS: Time to allow solve command to start actual claude process
@@ -89,16 +283,59 @@ export function thresholdToPercent(ratio) {
89
283
  * @see https://github.com/link-assistant/hive-mind/issues/1242
90
284
  */
91
285
  export const DISPLAY_THRESHOLDS = {
92
- RAM: thresholdToPercent(QUEUE_CONFIG.RAM_THRESHOLD), // Blocks at 65%
93
- CPU: thresholdToPercent(QUEUE_CONFIG.CPU_THRESHOLD), // Blocks at 65%
94
- DISK: thresholdToPercent(QUEUE_CONFIG.DISK_THRESHOLD), // One-at-a-time at 90%
95
- CLAUDE_5_HOUR_SESSION: thresholdToPercent(QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD), // One-at-a-time at 65%
96
- CLAUDE_WEEKLY: thresholdToPercent(QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD), // One-at-a-time at 97%
97
- GITHUB_API: thresholdToPercent(QUEUE_CONFIG.GITHUB_API_THRESHOLD), // Blocks parallel claude at 75%
286
+ RAM: thresholdToPercent(QUEUE_CONFIG.RAM_THRESHOLD),
287
+ CPU: thresholdToPercent(QUEUE_CONFIG.CPU_THRESHOLD),
288
+ DISK: thresholdToPercent(QUEUE_CONFIG.DISK_THRESHOLD),
289
+ CLAUDE_5_HOUR_SESSION: thresholdToPercent(QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD),
290
+ CLAUDE_WEEKLY: thresholdToPercent(QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD),
291
+ GITHUB_API: thresholdToPercent(QUEUE_CONFIG.GITHUB_API_THRESHOLD),
98
292
  };
99
293
 
294
+ /**
295
+ * Get strategy for a specific metric
296
+ * @param {string} metric - Metric name (ram, cpu, disk, claude5Hour, claudeWeekly, githubApi)
297
+ * @returns {string} Strategy ('reject', 'enqueue', 'dequeue-one-at-a-time')
298
+ */
299
+ export function getStrategy(metric) {
300
+ const threshold = QUEUE_CONFIG.thresholds[metric];
301
+ return threshold ? threshold.strategy : 'enqueue';
302
+ }
303
+
304
+ /**
305
+ * Check if a metric uses the reject strategy
306
+ * @param {string} metric - Metric name
307
+ * @returns {boolean}
308
+ */
309
+ export function isRejectStrategy(metric) {
310
+ return getStrategy(metric) === 'reject';
311
+ }
312
+
313
+ /**
314
+ * Check if a metric uses the enqueue strategy
315
+ * @param {string} metric - Metric name
316
+ * @returns {boolean}
317
+ */
318
+ export function isEnqueueStrategy(metric) {
319
+ return getStrategy(metric) === 'enqueue';
320
+ }
321
+
322
+ /**
323
+ * Check if a metric uses the dequeue-one-at-a-time strategy
324
+ * @param {string} metric - Metric name
325
+ * @returns {boolean}
326
+ */
327
+ export function isOneAtATimeStrategy(metric) {
328
+ return getStrategy(metric) === 'dequeue-one-at-a-time';
329
+ }
330
+
100
331
  export default {
101
332
  QUEUE_CONFIG,
102
333
  DISPLAY_THRESHOLDS,
334
+ THRESHOLD_STRATEGIES,
103
335
  thresholdToPercent,
336
+ parseQueueConfig,
337
+ getStrategy,
338
+ isRejectStrategy,
339
+ isEnqueueStrategy,
340
+ isOneAtATimeStrategy,
104
341
  };
@@ -26,7 +26,8 @@ import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getC
26
26
  // Import centralized queue configuration
27
27
  // This ensures thresholds are consistent between queue logic and display formatting
28
28
  // See: https://github.com/link-assistant/hive-mind/issues/1242
29
- export { QUEUE_CONFIG } from './queue-config.lib.mjs';
29
+ // See: https://github.com/link-assistant/hive-mind/issues/1253 (configurable strategies)
30
+ export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
30
31
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
31
32
 
32
33
  /**
@@ -531,14 +532,22 @@ export class SolveQueue {
531
532
  * - Processing count for Claude limits only includes Claude items, not agent items.
532
533
  * - This allows agent tasks to run in parallel when Claude limits are reached.
533
534
  *
535
+ * Logic per issue #1253:
536
+ * - All thresholds now support configurable strategies (reject, enqueue, dequeue-one-at-a-time)
537
+ * - 'reject' strategy immediately rejects the command without queueing
538
+ * - 'enqueue' blocks and waits in queue until metric drops
539
+ * - 'dequeue-one-at-a-time' allows one command while blocking subsequent
540
+ *
534
541
  * @param {Object} options - Options for the check
535
542
  * @param {string} options.tool - The tool being used ('claude', 'agent', etc.)
536
- * @returns {Promise<{canStart: boolean, reason?: string, reasons?: string[], oneAtATime?: boolean}>}
543
+ * @returns {Promise<{canStart: boolean, rejected?: boolean, rejectReason?: string, reason?: string, reasons?: string[], oneAtATime?: boolean}>}
537
544
  */
538
545
  async canStartCommand(options = {}) {
539
546
  const tool = options.tool || 'claude';
540
547
  const reasons = [];
541
548
  let oneAtATime = false;
549
+ let rejected = false;
550
+ let rejectReason = null;
542
551
 
543
552
  // Check minimum interval since last start FOR THIS TOOL
544
553
  // Each tool queue has independent timing to prevent cross-blocking
@@ -572,23 +581,33 @@ export class SolveQueue {
572
581
  this.recordThrottle('claude_running');
573
582
  }
574
583
 
575
- // Check system resources (RAM, CPU block unconditionally; disk uses one-at-a-time mode)
584
+ // Check system resources with strategy support
576
585
  // System resources apply to ALL tools, not just Claude
577
586
  // See: https://github.com/link-assistant/hive-mind/issues/1155
587
+ // See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
578
588
  const resourceCheck = await this.checkSystemResources(totalProcessing);
579
- if (!resourceCheck.ok) {
589
+ if (resourceCheck.rejected) {
590
+ rejected = true;
591
+ rejectReason = resourceCheck.rejectReason;
592
+ }
593
+ if (!resourceCheck.ok && !resourceCheck.rejected) {
580
594
  reasons.push(...resourceCheck.reasons);
581
595
  }
582
596
  if (resourceCheck.oneAtATime) {
583
597
  oneAtATime = true;
584
598
  }
585
599
 
586
- // Check API limits (pass hasRunningClaude, claudeProcessingCount, and tool)
600
+ // Check API limits with strategy support (pass hasRunningClaude, claudeProcessingCount, and tool)
587
601
  // Claude limits use claudeProcessingCount (only Claude items), not totalProcessing
588
602
  // This allows agent tasks to proceed when Claude limits are reached
589
603
  // See: https://github.com/link-assistant/hive-mind/issues/1159
604
+ // See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
590
605
  const limitCheck = await this.checkApiLimits(hasRunningClaude, claudeProcessingCount, tool);
591
- if (!limitCheck.ok) {
606
+ if (limitCheck.rejected) {
607
+ rejected = true;
608
+ rejectReason = limitCheck.rejectReason;
609
+ }
610
+ if (!limitCheck.ok && !limitCheck.rejected) {
592
611
  reasons.push(...limitCheck.reasons);
593
612
  }
594
613
  if (limitCheck.oneAtATime) {
@@ -604,14 +623,20 @@ export class SolveQueue {
604
623
  reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
605
624
  }
606
625
 
607
- const canStart = reasons.length === 0;
626
+ const canStart = reasons.length === 0 && !rejected;
608
627
 
609
628
  if (!canStart && this.verbose) {
610
- this.log(`Cannot start: ${reasons.join(', ')}`);
629
+ if (rejected) {
630
+ this.log(`Rejected: ${rejectReason}`);
631
+ } else {
632
+ this.log(`Cannot start: ${reasons.join(', ')}`);
633
+ }
611
634
  }
612
635
 
613
636
  return {
614
637
  canStart,
638
+ rejected,
639
+ rejectReason,
615
640
  reason: reasons.length > 0 ? reasons.join('\n') : undefined,
616
641
  reasons,
617
642
  oneAtATime,
@@ -628,33 +653,53 @@ export class SolveQueue {
628
653
  * This provides a more stable metric that isn't affected by brief spikes
629
654
  * during claude process startup.
630
655
  *
631
- * Resource threshold modes:
632
- * - RAM_THRESHOLD: Enqueue mode - blocks all commands unconditionally
633
- * - CPU_THRESHOLD: Enqueue mode - blocks all commands unconditionally
634
- * - DISK_THRESHOLD: One-at-a-time mode - allows exactly one command when nothing is processing
656
+ * Resource threshold modes are now configurable via HIVE_MIND_QUEUE_CONFIG:
657
+ * - 'reject': Immediately reject the command, no queueing
658
+ * - 'enqueue': Block all commands unconditionally until metric drops
659
+ * - 'dequeue-one-at-a-time': Allow one command when above threshold
660
+ *
661
+ * Default strategies:
662
+ * - RAM: enqueue
663
+ * - CPU: enqueue
664
+ * - DISK: reject (changed from dequeue-one-at-a-time - queue lost on restart)
635
665
  *
636
666
  * See: https://github.com/link-assistant/hive-mind/issues/1155
667
+ * See: https://github.com/link-assistant/hive-mind/issues/1253
637
668
  *
638
669
  * @param {number} totalProcessing - Total processing count (queue + external claude processes)
639
- * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
670
+ * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
640
671
  */
641
672
  async checkSystemResources(totalProcessing = 0) {
642
673
  const reasons = [];
643
674
  let oneAtATime = false;
675
+ let rejected = false;
676
+ let rejectReason = null;
644
677
 
645
678
  // Check RAM (using cached value)
646
- // Enqueue mode: blocks all commands unconditionally
647
679
  const memResult = await getCachedMemoryInfo(this.verbose);
648
680
  if (memResult.success) {
649
681
  const usedRatio = memResult.memory.usedPercentage / 100;
650
- if (usedRatio >= QUEUE_CONFIG.RAM_THRESHOLD) {
651
- reasons.push(formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.RAM_THRESHOLD));
652
- this.recordThrottle('ram_high');
682
+ if (usedRatio >= QUEUE_CONFIG.thresholds.ram.value) {
683
+ const reason = formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.thresholds.ram.value);
684
+ const strategy = QUEUE_CONFIG.thresholds.ram.strategy;
685
+ this.recordThrottle(`ram_${strategy}`);
686
+
687
+ if (strategy === 'reject') {
688
+ rejected = true;
689
+ rejectReason = reason;
690
+ } else if (strategy === 'dequeue-one-at-a-time') {
691
+ oneAtATime = true;
692
+ if (totalProcessing > 0) {
693
+ reasons.push(reason + ' (waiting for current command)');
694
+ }
695
+ } else {
696
+ // 'enqueue' - block unconditionally
697
+ reasons.push(reason);
698
+ }
653
699
  }
654
700
  }
655
701
 
656
702
  // Check CPU using 5-minute load average (more stable than 1-minute)
657
- // Enqueue mode: blocks all commands unconditionally
658
703
  // Cache TTL is 2 minutes, which is appropriate for this metric
659
704
  const cpuResult = await getCachedCpuInfo(this.verbose);
660
705
  if (cpuResult.success) {
@@ -671,33 +716,55 @@ export class SolveQueue {
671
716
  this.log(`CPU 5m load avg: ${loadAvg5.toFixed(2)}, cpus: ${cpuCount}, usage: ${usagePercent}%`);
672
717
  }
673
718
 
674
- if (usageRatio >= QUEUE_CONFIG.CPU_THRESHOLD) {
675
- reasons.push(formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.CPU_THRESHOLD));
676
- this.recordThrottle('cpu_high');
719
+ if (usageRatio >= QUEUE_CONFIG.thresholds.cpu.value) {
720
+ const reason = formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.thresholds.cpu.value);
721
+ const strategy = QUEUE_CONFIG.thresholds.cpu.strategy;
722
+ this.recordThrottle(`cpu_${strategy}`);
723
+
724
+ if (strategy === 'reject') {
725
+ rejected = true;
726
+ rejectReason = reason;
727
+ } else if (strategy === 'dequeue-one-at-a-time') {
728
+ oneAtATime = true;
729
+ if (totalProcessing > 0) {
730
+ reasons.push(reason + ' (waiting for current command)');
731
+ }
732
+ } else {
733
+ // 'enqueue' - block unconditionally
734
+ reasons.push(reason);
735
+ }
677
736
  }
678
737
  }
679
738
 
680
739
  // Check disk space (using cached value)
681
- // One-at-a-time mode: allows exactly one command when nothing is processing
682
- // Unlike RAM and CPU which block unconditionally, disk uses one-at-a-time mode
683
- // because we cannot predict how much disk space a task will use
684
- // See: https://github.com/link-assistant/hive-mind/issues/1155
740
+ // Default strategy changed to 'reject' because queue is lost on restart anyway
741
+ // See: https://github.com/link-assistant/hive-mind/issues/1253
685
742
  const diskResult = await getCachedDiskInfo(this.verbose);
686
743
  if (diskResult.success) {
687
744
  // Calculate usage from free percentage
688
745
  const usedPercent = 100 - diskResult.diskSpace.freePercentage;
689
746
  const usedRatio = usedPercent / 100;
690
- if (usedRatio >= QUEUE_CONFIG.DISK_THRESHOLD) {
691
- oneAtATime = true;
692
- this.recordThrottle('disk_high');
693
- // Only block if something is already processing (one-at-a-time mode)
694
- if (totalProcessing > 0) {
695
- reasons.push(formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.DISK_THRESHOLD) + ' (waiting for current command)');
747
+ if (usedRatio >= QUEUE_CONFIG.thresholds.disk.value) {
748
+ const reason = formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.thresholds.disk.value);
749
+ const strategy = QUEUE_CONFIG.thresholds.disk.strategy;
750
+ this.recordThrottle(`disk_${strategy}`);
751
+
752
+ if (strategy === 'reject') {
753
+ rejected = true;
754
+ rejectReason = reason;
755
+ } else if (strategy === 'dequeue-one-at-a-time') {
756
+ oneAtATime = true;
757
+ if (totalProcessing > 0) {
758
+ reasons.push(reason + ' (waiting for current command)');
759
+ }
760
+ } else {
761
+ // 'enqueue' - block unconditionally
762
+ reasons.push(reason);
696
763
  }
697
764
  }
698
765
  }
699
766
 
700
- return { ok: reasons.length === 0, reasons, oneAtATime };
767
+ return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
701
768
  }
702
769
 
703
770
  /**
@@ -714,14 +781,20 @@ export class SolveQueue {
714
781
  * - For Claude limits, only count Claude-specific processing items, not agent items.
715
782
  * This allows agent tasks to run in parallel even when Claude limits are reached.
716
783
  *
784
+ * Logic per issue #1253:
785
+ * - All thresholds now support configurable strategies (reject, enqueue, dequeue-one-at-a-time)
786
+ * - Configuration via HIVE_MIND_QUEUE_CONFIG or individual env vars
787
+ *
717
788
  * @param {boolean} hasRunningClaude - Whether claude processes are running (from pgrep)
718
789
  * @param {number} claudeProcessingCount - Count of 'claude' tool items being processed in queue
719
790
  * @param {string} tool - The tool being used ('claude', 'agent', etc.)
720
- * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
791
+ * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
721
792
  */
722
793
  async checkApiLimits(hasRunningClaude = false, claudeProcessingCount = 0, tool = 'claude') {
723
794
  const reasons = [];
724
795
  let oneAtATime = false;
796
+ let rejected = false;
797
+ let rejectReason = null;
725
798
 
726
799
  // Apply Claude-specific limits only when tool is 'claude'
727
800
  // Other tools (like 'agent') use different rate limiting backends and are not
@@ -744,32 +817,51 @@ export class SolveQueue {
744
817
  const weeklyPercent = claudeResult.usage.allModels.percentage;
745
818
 
746
819
  // Session limit (5-hour)
747
- // When above threshold: allow exactly one Claude command, block if any Claude processing
748
- // Only counts Claude-specific processing, not agent items
749
- // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159
820
+ // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_5_HOUR_SESSION_STRATEGY
821
+ // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
750
822
  if (sessionPercent !== null) {
751
823
  const sessionRatio = sessionPercent / 100;
752
- if (sessionRatio >= QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD) {
753
- oneAtATime = true;
754
- this.recordThrottle(sessionRatio >= 1.0 ? 'claude_5_hour_session_100' : 'claude_5_hour_session_high');
755
- // Use totalClaudeProcessing for Claude-specific one-at-a-time checking
756
- if (totalClaudeProcessing > 0) {
757
- reasons.push(formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.CLAUDE_5_HOUR_SESSION_THRESHOLD) + ' (waiting for current command)');
824
+ if (sessionRatio >= QUEUE_CONFIG.thresholds.claude5Hour.value) {
825
+ const reason = formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.claude5Hour.value);
826
+ const strategy = QUEUE_CONFIG.thresholds.claude5Hour.strategy;
827
+ this.recordThrottle(sessionRatio >= 1.0 ? 'claude_5_hour_session_100' : `claude_5_hour_session_${strategy}`);
828
+
829
+ if (strategy === 'reject') {
830
+ rejected = true;
831
+ rejectReason = reason;
832
+ } else if (strategy === 'dequeue-one-at-a-time') {
833
+ oneAtATime = true;
834
+ if (totalClaudeProcessing > 0) {
835
+ reasons.push(reason + ' (waiting for current command)');
836
+ }
837
+ } else {
838
+ // 'enqueue' - block unconditionally
839
+ reasons.push(reason);
758
840
  }
759
841
  }
760
842
  }
761
843
 
762
844
  // Weekly limit
763
- // When above threshold: allow exactly one Claude command, block if one is in progress
845
+ // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_CLAUDE_WEEKLY_STRATEGY
846
+ // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159, #1253
764
847
  if (weeklyPercent !== null) {
765
848
  const weeklyRatio = weeklyPercent / 100;
766
- if (weeklyRatio >= QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD) {
767
- oneAtATime = true;
768
- this.recordThrottle(weeklyRatio >= 1.0 ? 'claude_weekly_100' : 'claude_weekly_high');
769
- // Use totalClaudeProcessing for Claude-specific one-at-a-time checking
770
- // See: https://github.com/link-assistant/hive-mind/issues/1133, #1159
771
- if (totalClaudeProcessing > 0) {
772
- reasons.push(formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD) + ' (waiting for current command)');
849
+ if (weeklyRatio >= QUEUE_CONFIG.thresholds.claudeWeekly.value) {
850
+ const reason = formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.claudeWeekly.value);
851
+ const strategy = QUEUE_CONFIG.thresholds.claudeWeekly.strategy;
852
+ this.recordThrottle(weeklyRatio >= 1.0 ? 'claude_weekly_100' : `claude_weekly_${strategy}`);
853
+
854
+ if (strategy === 'reject') {
855
+ rejected = true;
856
+ rejectReason = reason;
857
+ } else if (strategy === 'dequeue-one-at-a-time') {
858
+ oneAtATime = true;
859
+ if (totalClaudeProcessing > 0) {
860
+ reasons.push(reason + ' (waiting for current command)');
861
+ }
862
+ } else {
863
+ // 'enqueue' - block unconditionally
864
+ reasons.push(reason);
773
865
  }
774
866
  }
775
867
  }
@@ -779,19 +871,34 @@ export class SolveQueue {
779
871
  }
780
872
 
781
873
  // Check GitHub limits (only relevant if claude processes running)
874
+ // Configurable strategy via HIVE_MIND_QUEUE_CONFIG or HIVE_MIND_GITHUB_API_STRATEGY
782
875
  if (hasRunningClaude) {
783
876
  const githubResult = await getCachedGitHubLimits(this.verbose);
784
877
  if (githubResult.success) {
785
878
  const usedPercent = githubResult.githubRateLimit.usedPercentage;
786
879
  const usedRatio = usedPercent / 100;
787
- if (usedRatio >= QUEUE_CONFIG.GITHUB_API_THRESHOLD) {
788
- reasons.push(formatWaitingReason('github', usedPercent, QUEUE_CONFIG.GITHUB_API_THRESHOLD));
789
- this.recordThrottle(usedRatio >= 1.0 ? 'github_100' : 'github_high');
880
+ if (usedRatio >= QUEUE_CONFIG.thresholds.githubApi.value) {
881
+ const reason = formatWaitingReason('github', usedPercent, QUEUE_CONFIG.thresholds.githubApi.value);
882
+ const strategy = QUEUE_CONFIG.thresholds.githubApi.strategy;
883
+ this.recordThrottle(usedRatio >= 1.0 ? 'github_100' : `github_${strategy}`);
884
+
885
+ if (strategy === 'reject') {
886
+ rejected = true;
887
+ rejectReason = reason;
888
+ } else if (strategy === 'dequeue-one-at-a-time') {
889
+ oneAtATime = true;
890
+ if (totalClaudeProcessing > 0) {
891
+ reasons.push(reason + ' (waiting for current command)');
892
+ }
893
+ } else {
894
+ // 'enqueue' - block unconditionally
895
+ reasons.push(reason);
896
+ }
790
897
  }
791
898
  }
792
899
  }
793
900
 
794
- return { ok: reasons.length === 0, reasons, oneAtATime };
901
+ return { ok: reasons.length === 0 && !rejected, reasons, oneAtATime, rejected, rejectReason };
795
902
  }
796
903
 
797
904
  /**