@link-assistant/hive-mind 1.20.1 → 1.21.1

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,32 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.21.1
4
+
5
+ ### Patch Changes
6
+
7
+ - fbfc0c3: Fix `--tool agent` pricing display for free models (Issue #1250)
8
+ - Add base model pricing lookup for free model variants (e.g., `kimi-k2.5-free` → `kimi-k2.5`)
9
+ - Show actual market price as "Public pricing estimate" based on the underlying paid model
10
+ - Display base model reference in cost output: "(based on Moonshot AI kimi-k2.5 prices)"
11
+ - Distinguish between truly free models and free access to paid models
12
+ - Fix token usage showing "0 input, 0 output" by accumulating tokens during streaming
13
+ - Token accumulation now happens in real-time as step_finish events arrive, avoiding NDJSON concatenation issues
14
+
15
+ ## 1.21.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 6cf54b7: Add configurable queue threshold strategies (reject, enqueue, dequeue-one-at-a-time)
20
+ - Add three handling strategies for each queue threshold:
21
+ - `reject`: Immediately reject the command, no queueing
22
+ - `enqueue`: Block and wait in queue until metric drops
23
+ - `dequeue-one-at-a-time`: Allow one command, block subsequent
24
+ - Support configuration via `HIVE_MIND_QUEUE_CONFIG` environment variable (links notation format)
25
+ - Support individual strategy env vars (e.g., `HIVE_MIND_DISK_STRATEGY`)
26
+
27
+ **Breaking change:** Disk threshold default strategy changed from `dequeue-one-at-a-time` to `reject`
28
+ because the queue is lost on server restart. To restore old behavior: `HIVE_MIND_DISK_STRATEGY=dequeue-one-at-a-time`
29
+
3
30
  ## 1.20.1
4
31
 
5
32
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.20.1",
3
+ "version": "1.21.1",
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
@@ -100,11 +100,59 @@ const getOriginalProviderName = providerId => {
100
100
  return providerMap[providerId] || providerId.charAt(0).toUpperCase() + providerId.slice(1);
101
101
  };
102
102
 
103
+ /**
104
+ * Issue #1250: Normalize model name and find base model for pricing lookup
105
+ * Free models like "kimi-k2.5-free" should use pricing from base model "kimi-k2.5"
106
+ *
107
+ * @param {string} modelName - The model name (e.g., 'kimi-k2.5-free')
108
+ * @returns {Object} Object with:
109
+ * - baseModelName: The base model name for pricing lookup
110
+ * - isFreeVariant: Whether this is a free variant
111
+ */
112
+ const getBaseModelForPricing = modelName => {
113
+ // Known mappings for free models to their base paid versions
114
+ const freeToBaseMap = {
115
+ 'kimi-k2.5-free': 'kimi-k2.5',
116
+ 'glm-4.7-free': 'glm-4.7',
117
+ 'minimax-m2.1-free': 'minimax-m2.1',
118
+ 'trinity-large-preview-free': 'trinity-large-preview',
119
+ // Grok models don't have a paid equivalent with same name
120
+ // These are kept as-is since they're truly free
121
+ };
122
+
123
+ // Check if there's a direct mapping
124
+ if (freeToBaseMap[modelName]) {
125
+ return {
126
+ baseModelName: freeToBaseMap[modelName],
127
+ isFreeVariant: true,
128
+ };
129
+ }
130
+
131
+ // Try removing "-free" suffix
132
+ if (modelName.endsWith('-free')) {
133
+ return {
134
+ baseModelName: modelName.replace(/-free$/, ''),
135
+ isFreeVariant: true,
136
+ };
137
+ }
138
+
139
+ // Not a free variant
140
+ return {
141
+ baseModelName: modelName,
142
+ isFreeVariant: false,
143
+ };
144
+ };
145
+
103
146
  /**
104
147
  * Calculate pricing for agent tool usage using models.dev API
105
148
  * Issue #1250: Shows actual provider (OpenCode Zen) and calculates public pricing estimate
106
149
  * based on original provider prices (Moonshot AI, OpenAI, Anthropic, etc.)
107
150
  *
151
+ * For free models like "kimi-k2.5-free", this function:
152
+ * 1. First fetches the free model info to get the model name
153
+ * 2. Then fetches the base model (e.g., "kimi-k2.5") for actual pricing
154
+ * 3. Calculates public pricing estimate based on the base model's cost
155
+ *
108
156
  * @param {string} modelId - The model ID used (e.g., 'opencode/grok-code')
109
157
  * @param {Object} tokenUsage - Token usage data from parseAgentTokenUsage
110
158
  * @returns {Object} Pricing information with:
@@ -122,40 +170,66 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
122
170
  const providerFromModel = modelId.includes('/') ? modelId.split('/')[0] : null;
123
171
 
124
172
  // Get original provider name for pricing reference
125
- const originalProvider = getOriginalProviderName(providerFromModel);
173
+ let originalProvider = getOriginalProviderName(providerFromModel);
126
174
 
127
175
  try {
128
176
  // Fetch model info from models.dev API
129
- const modelInfo = await fetchModelInfo(modelName);
177
+ let modelInfo = await fetchModelInfo(modelName);
178
+
179
+ // Issue #1250: Check if model has zero pricing (free model from OpenCode Zen)
180
+ // If so, look up the base model for actual public pricing estimate
181
+ const { baseModelName, isFreeVariant } = getBaseModelForPricing(modelName);
182
+ let baseModelInfo = null;
183
+ let pricingCost = modelInfo?.cost;
184
+
185
+ if (modelInfo && modelInfo.cost && modelInfo.cost.input === 0 && modelInfo.cost.output === 0 && baseModelName !== modelName) {
186
+ // This is a free model with zero pricing - look up base model for public pricing
187
+ baseModelInfo = await fetchModelInfo(baseModelName);
188
+ if (baseModelInfo && baseModelInfo.cost) {
189
+ // Use base model pricing for public estimate
190
+ pricingCost = baseModelInfo.cost;
191
+ // Update original provider from base model if available
192
+ if (baseModelInfo.provider && !originalProvider) {
193
+ originalProvider = baseModelInfo.provider;
194
+ }
195
+ }
196
+ }
130
197
 
131
- if (modelInfo && modelInfo.cost) {
132
- const cost = modelInfo.cost;
198
+ if (modelInfo || baseModelInfo) {
199
+ const effectiveModelInfo = modelInfo || baseModelInfo;
200
+ const cost = pricingCost || { input: 0, output: 0, cache_read: 0, cache_write: 0, reasoning: 0 };
133
201
 
134
202
  // Calculate public pricing estimate based on original provider prices
135
203
  // Prices are per 1M tokens, so divide by 1,000,000
204
+ // All priced components from models.dev: input, output, cache_read, cache_write, reasoning
136
205
  const inputCost = (tokenUsage.inputTokens * (cost.input || 0)) / 1_000_000;
137
206
  const outputCost = (tokenUsage.outputTokens * (cost.output || 0)) / 1_000_000;
138
207
  const cacheReadCost = (tokenUsage.cacheReadTokens * (cost.cache_read || 0)) / 1_000_000;
139
208
  const cacheWriteCost = (tokenUsage.cacheWriteTokens * (cost.cache_write || 0)) / 1_000_000;
209
+ const reasoningCost = (tokenUsage.reasoningTokens * (cost.reasoning || 0)) / 1_000_000;
140
210
 
141
- const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
211
+ const totalCost = inputCost + outputCost + cacheReadCost + cacheWriteCost + reasoningCost;
142
212
 
143
213
  // Determine if this is a free model from OpenCode Zen
144
214
  // 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';
215
+ const isOpencodeFreeModel = providerFromModel === 'opencode' || isFreeVariant || modelName.toLowerCase().includes('free') || modelName.toLowerCase().includes('grok') || providerFromModel === 'moonshot' || providerFromModel === 'openai' || providerFromModel === 'anthropic';
216
+
217
+ // Use base model's provider for original provider reference if available
218
+ const effectiveOriginalProvider = baseModelInfo?.provider || originalProvider || effectiveModelInfo?.provider || null;
146
219
 
147
220
  return {
148
221
  modelId,
149
- modelName: modelInfo.name || modelName,
222
+ modelName: effectiveModelInfo?.name || modelName,
150
223
  // Issue #1250: Always show OpenCode Zen as actual provider
151
224
  provider: 'OpenCode Zen',
152
225
  // Store original provider for reference in pricing display
153
- originalProvider: originalProvider || modelInfo.provider || null,
226
+ originalProvider: effectiveOriginalProvider,
154
227
  pricing: {
155
228
  inputPerMillion: cost.input || 0,
156
229
  outputPerMillion: cost.output || 0,
157
230
  cacheReadPerMillion: cost.cache_read || 0,
158
231
  cacheWritePerMillion: cost.cache_write || 0,
232
+ reasoningPerMillion: cost.reasoning || 0,
159
233
  },
160
234
  tokenUsage,
161
235
  breakdown: {
@@ -163,15 +237,18 @@ export const calculateAgentPricing = async (modelId, tokenUsage) => {
163
237
  output: outputCost,
164
238
  cacheRead: cacheReadCost,
165
239
  cacheWrite: cacheWriteCost,
240
+ reasoning: reasoningCost,
166
241
  },
167
- // Public pricing estimate based on original provider prices
242
+ // Public pricing estimate based on original/base model prices
168
243
  totalCostUSD: totalCost,
169
244
  // Actual cost from OpenCode Zen (free for supported models)
170
245
  opencodeCost: isOpencodeFreeModel ? 0 : totalCost,
171
- // Keep for backward compatibility - indicates if model has zero pricing
172
- isFreeModel: cost.input === 0 && cost.output === 0,
246
+ // Keep for backward compatibility - indicates if the accessed model has zero pricing
247
+ isFreeModel: modelInfo?.cost?.input === 0 && modelInfo?.cost?.output === 0,
173
248
  // New flag to indicate if OpenCode Zen provides this model for free
174
249
  isOpencodeFreeModel,
250
+ // Issue #1250: Include base model info for transparency
251
+ baseModelName: baseModelName !== modelName ? baseModelName : null,
175
252
  };
176
253
  }
177
254
  // Model not found in API, return what we have
@@ -476,11 +553,39 @@ export const executeAgentCommand = async params => {
476
553
  let limitReached = false;
477
554
  let limitResetTime = null;
478
555
  let lastMessage = '';
479
- let fullOutput = ''; // Collect all output for pricing calculation and error detection
556
+ let fullOutput = ''; // Collect all output for error detection (kept for backward compatibility)
480
557
  // Issue #1201: Track error events detected during streaming for reliable error detection
481
558
  // Post-hoc detection on fullOutput can miss errors if NDJSON lines get concatenated without newlines
482
559
  let streamingErrorDetected = false;
483
560
  let streamingErrorMessage = null;
561
+ // Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
562
+ // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
563
+ const streamingTokenUsage = {
564
+ inputTokens: 0,
565
+ outputTokens: 0,
566
+ reasoningTokens: 0,
567
+ cacheReadTokens: 0,
568
+ cacheWriteTokens: 0,
569
+ totalCost: 0,
570
+ stepCount: 0,
571
+ };
572
+ // Helper to accumulate tokens from step_finish events during streaming
573
+ const accumulateTokenUsage = data => {
574
+ if (data.type === 'step_finish' && data.part?.tokens) {
575
+ const tokens = data.part.tokens;
576
+ streamingTokenUsage.stepCount++;
577
+ if (tokens.input) streamingTokenUsage.inputTokens += tokens.input;
578
+ if (tokens.output) streamingTokenUsage.outputTokens += tokens.output;
579
+ if (tokens.reasoning) streamingTokenUsage.reasoningTokens += tokens.reasoning;
580
+ if (tokens.cache) {
581
+ if (tokens.cache.read) streamingTokenUsage.cacheReadTokens += tokens.cache.read;
582
+ if (tokens.cache.write) streamingTokenUsage.cacheWriteTokens += tokens.cache.write;
583
+ }
584
+ if (data.part.cost !== undefined) {
585
+ streamingTokenUsage.totalCost += data.part.cost;
586
+ }
587
+ }
588
+ };
484
589
 
485
590
  for await (const chunk of execCommand.stream()) {
486
591
  if (chunk.type === 'stdout') {
@@ -500,6 +605,8 @@ export const executeAgentCommand = async params => {
500
605
  sessionId = data.sessionID;
501
606
  await log(`📌 Session ID: ${sessionId}`);
502
607
  }
608
+ // Issue #1250: Accumulate token usage during streaming
609
+ accumulateTokenUsage(data);
503
610
  // Issue #1201: Detect error events during streaming for reliable detection
504
611
  if (data.type === 'error' || data.type === 'step_error') {
505
612
  streamingErrorDetected = true;
@@ -532,6 +639,8 @@ export const executeAgentCommand = async params => {
532
639
  sessionId = stderrData.sessionID;
533
640
  await log(`📌 Session ID: ${sessionId}`);
534
641
  }
642
+ // Issue #1250: Accumulate token usage during streaming (stderr)
643
+ accumulateTokenUsage(stderrData);
535
644
  // Issue #1201: Detect error events during streaming (stderr) for reliable detection
536
645
  if (stderrData.type === 'error' || stderrData.type === 'step_error') {
537
646
  streamingErrorDetected = true;
@@ -646,8 +755,9 @@ export const executeAgentCommand = async params => {
646
755
  await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
647
756
  await log(` Load: ${resourcesAfter.load}`, { verbose: true });
648
757
 
649
- // Parse token usage even on failure (partial work may have been done)
650
- const tokenUsage = parseAgentTokenUsage(fullOutput);
758
+ // Issue #1250: Use streaming-accumulated token usage instead of re-parsing fullOutput
759
+ // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
760
+ const tokenUsage = streamingTokenUsage;
651
761
  const pricingInfo = await calculateAgentPricing(mappedModel, tokenUsage);
652
762
 
653
763
  return {
@@ -664,29 +774,47 @@ export const executeAgentCommand = async params => {
664
774
 
665
775
  await log('\n\n✅ Agent command completed');
666
776
 
667
- // Parse token usage from collected output
668
- const tokenUsage = parseAgentTokenUsage(fullOutput);
777
+ // Issue #1250: Use streaming-accumulated token usage instead of re-parsing fullOutput
778
+ // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
779
+ const tokenUsage = streamingTokenUsage;
669
780
  const pricingInfo = await calculateAgentPricing(mappedModel, tokenUsage);
670
781
 
671
- // Log pricing information
782
+ // Log pricing information (similar to --tool claude breakdown)
672
783
  if (tokenUsage.stepCount > 0) {
673
784
  await log('\n💰 Token Usage Summary:');
674
- await log(` 📊 ${pricingInfo.modelName || mappedModel}:`);
675
- await log(` Input tokens: ${tokenUsage.inputTokens.toLocaleString()}`);
676
- await log(` Output tokens: ${tokenUsage.outputTokens.toLocaleString()}`);
785
+ await log(` 📊 ${pricingInfo.modelName || mappedModel} (${tokenUsage.stepCount} steps):`);
786
+ await log(` Input tokens: ${tokenUsage.inputTokens.toLocaleString()}`);
787
+ await log(` Output tokens: ${tokenUsage.outputTokens.toLocaleString()}`);
677
788
  if (tokenUsage.reasoningTokens > 0) {
678
789
  await log(` Reasoning tokens: ${tokenUsage.reasoningTokens.toLocaleString()}`);
679
790
  }
680
791
  if (tokenUsage.cacheReadTokens > 0 || tokenUsage.cacheWriteTokens > 0) {
681
- await log(` Cache read: ${tokenUsage.cacheReadTokens.toLocaleString()}`);
682
- await log(` Cache write: ${tokenUsage.cacheWriteTokens.toLocaleString()}`);
792
+ await log(` Cache read: ${tokenUsage.cacheReadTokens.toLocaleString()}`);
793
+ await log(` Cache write: ${tokenUsage.cacheWriteTokens.toLocaleString()}`);
683
794
  }
684
795
 
685
- if (pricingInfo.totalCostUSD !== null) {
686
- if (pricingInfo.isFreeModel) {
687
- await log(' Cost: $0.00 (Free model)');
688
- } else {
689
- await log(` Cost: $${pricingInfo.totalCostUSD.toFixed(6)}`);
796
+ if (pricingInfo.totalCostUSD !== null && pricingInfo.breakdown) {
797
+ // Show per-component cost breakdown (similar to --tool claude)
798
+ await log(' Cost breakdown:');
799
+ await log(` Input: $${pricingInfo.breakdown.input.toFixed(6)} (${(pricingInfo.pricing?.inputPerMillion || 0).toFixed(2)}/M tokens)`);
800
+ await log(` Output: $${pricingInfo.breakdown.output.toFixed(6)} (${(pricingInfo.pricing?.outputPerMillion || 0).toFixed(2)}/M tokens)`);
801
+ if (tokenUsage.cacheReadTokens > 0) {
802
+ await log(` Cache read: $${pricingInfo.breakdown.cacheRead.toFixed(6)} (${(pricingInfo.pricing?.cacheReadPerMillion || 0).toFixed(2)}/M tokens)`);
803
+ }
804
+ if (tokenUsage.cacheWriteTokens > 0) {
805
+ await log(` Cache write: $${pricingInfo.breakdown.cacheWrite.toFixed(6)} (${(pricingInfo.pricing?.cacheWritePerMillion || 0).toFixed(2)}/M tokens)`);
806
+ }
807
+ if (tokenUsage.reasoningTokens > 0 && pricingInfo.breakdown.reasoning > 0) {
808
+ await log(` Reasoning: $${pricingInfo.breakdown.reasoning.toFixed(6)} (${(pricingInfo.pricing?.reasoningPerMillion || 0).toFixed(2)}/M tokens)`);
809
+ }
810
+ // Show public pricing estimate
811
+ const pricingRef = pricingInfo.baseModelName && pricingInfo.originalProvider ? ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)` : pricingInfo.originalProvider ? ` (based on ${pricingInfo.originalProvider} prices)` : '';
812
+ await log(` Public pricing estimate: $${pricingInfo.totalCostUSD.toFixed(6)}${pricingRef}`);
813
+ // Show actual OpenCode Zen cost
814
+ if (pricingInfo.isOpencodeFreeModel) {
815
+ await log(' Calculated by OpenCode Zen: $0.00 (Free model)');
816
+ } else if (pricingInfo.opencodeCost !== undefined) {
817
+ await log(` Calculated by OpenCode Zen: $${pricingInfo.opencodeCost.toFixed(6)}`);
690
818
  }
691
819
  await log(` Provider: ${pricingInfo.provider || 'OpenCode Zen'}`);
692
820
  } else {
@@ -30,6 +30,7 @@ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
30
30
  * - opencodeCost: Actual billed cost from OpenCode Zen (for agent tool)
31
31
  * - isOpencodeFreeModel: Whether OpenCode Zen provides this model for free
32
32
  * - originalProvider: Original provider for pricing reference
33
+ * - baseModelName: Base model name if pricing was derived from base model (Issue #1250)
33
34
  * @returns {string} Formatted cost info string for markdown (empty if no data available)
34
35
  */
35
36
  const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
@@ -47,13 +48,20 @@ const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) =
47
48
  }
48
49
  // Issue #1250: Show public pricing estimate based on original provider prices
49
50
  if (hasPublic) {
50
- // For models with zero pricing from original provider, show as free
51
- if (pricingInfo?.isFreeModel && totalCostUSD === 0) {
51
+ // Issue #1250: For free models accessed via OpenCode Zen, show pricing based on base model
52
+ // Only show as completely free if the base model also has no pricing
53
+ if (pricingInfo?.isFreeModel && totalCostUSD === 0 && !pricingInfo?.baseModelName) {
52
54
  costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
53
55
  } else {
54
56
  // 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
+ // Issue #1250: Include base model reference when pricing comes from base model
58
+ let pricingRef = '';
59
+ if (pricingInfo?.baseModelName && pricingInfo?.originalProvider) {
60
+ pricingRef = ` (based on ${pricingInfo.originalProvider} ${pricingInfo.baseModelName} prices)`;
61
+ } else if (pricingInfo?.originalProvider) {
62
+ pricingRef = ` (based on ${pricingInfo.originalProvider} prices)`;
63
+ }
64
+ costInfo += `\n- Public pricing estimate: $${totalCostUSD.toFixed(6)}${pricingRef}`;
57
65
  }
58
66
  } else if (hasPricing) {
59
67
  costInfo += '\n- Public pricing estimate: unknown';
@@ -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
  /**