@link-assistant/hive-mind 1.22.5 → 1.22.6

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,26 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.22.6
4
+
5
+ ### Patch Changes
6
+
7
+ - ed87517: Fix: Add workaround for process stream hanging after completion (Issue #1280)
8
+
9
+ After the Claude CLI sends the final result event, the `for await` loop over
10
+ `command-stream`'s `stream()` can hang indefinitely. Root cause: `command-stream` v0.9.4's
11
+ `stream()` async iterator waits for both process exit AND stdout/stderr pipe close before
12
+ ending. If the CLI process keeps stdout open after sending the result, `pumpReadable()` hangs,
13
+ `finish()` never fires, and the stream iterator never terminates.
14
+
15
+ Additionally, `command-stream` v0.9.4 `stream()` does NOT yield `{type:'exit'}` chunks,
16
+ making the exit code detection via `chunk.type === 'exit'` dead code (exit code is obtained
17
+ from `execCommand.result.code` after the loop instead).
18
+
19
+ Workaround: after receiving the result event, start a configurable timeout (default 30s,
20
+ `HIVE_MIND_RESULT_STREAM_CLOSE_MS`) to force-kill the process with SIGTERM/SIGKILL.
21
+
22
+ Related: https://github.com/link-foundation/command-stream/issues/155
23
+
3
24
  ## 1.22.5
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.22.5",
3
+ "version": "1.22.6",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -17,7 +17,6 @@ import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
17
17
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
18
18
  import { CLAUDE_MODELS as availableModels } from './model-validation.lib.mjs'; // Issue #1221
19
19
  export { availableModels }; // Re-export for backward compatibility
20
-
21
20
  // Helper to display resume command at end of session
22
21
  const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) => {
23
22
  if (!sessionId || !tempDir) return;
@@ -25,7 +24,6 @@ const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) =>
25
24
  await log('\nšŸ’” To continue this session in Claude Code interactive mode:\n');
26
25
  await log(` ${cmd}\n`);
27
26
  };
28
-
29
27
  /** Format numbers with spaces as thousands separator (no commas) */
30
28
  export const formatNumber = num => {
31
29
  if (num === null || num === undefined) return 'N/A';
@@ -38,10 +36,7 @@ export const formatNumber = num => {
38
36
  // Model mapping to translate aliases to full model IDs
39
37
  // Supports [1m] suffix for 1 million token context (Issue #1221)
40
38
  export const mapModelToId = model => {
41
- if (!model || typeof model !== 'string') {
42
- return model;
43
- }
44
-
39
+ if (!model || typeof model !== 'string') return model;
45
40
  // Check for [1m] suffix (case-insensitive)
46
41
  const match = model.match(/^(.+?)\[1m\]$/i);
47
42
  if (match) {
@@ -49,7 +44,6 @@ export const mapModelToId = model => {
49
44
  const mappedBase = availableModels[baseModel] || baseModel;
50
45
  return `${mappedBase}[1m]`;
51
46
  }
52
-
53
47
  return availableModels[model] || model;
54
48
  };
55
49
  // Function to validate Claude CLI connection with retry logic
@@ -108,7 +102,6 @@ export const validateClaudeConnection = async (model = 'haiku-3') => {
108
102
  throw timeoutError;
109
103
  }
110
104
  }
111
-
112
105
  // Check for common error patterns
113
106
  const stdout = result.stdout?.toString() || '';
114
107
  const stderr = result.stderr?.toString() || '';
@@ -137,7 +130,6 @@ export const validateClaudeConnection = async (model = 'haiku-3') => {
137
130
  const jsonError = checkForJsonError(stdout) || checkForJsonError(stderr);
138
131
  // Check for API overload error pattern
139
132
  const isOverloadError = (stdout.includes('API Error: 500') && stdout.includes('Overloaded')) || (stderr.includes('API Error: 500') && stderr.includes('Overloaded')) || (jsonError && jsonError.type === 'api_error' && jsonError.message === 'Overloaded');
140
-
141
133
  // Handle overload errors with retry
142
134
  if (isOverloadError) {
143
135
  if (retryCount < maxRetries) {
@@ -223,47 +215,27 @@ export const validateClaudeConnection = async (model = 'haiku-3') => {
223
215
  return await attemptValidation();
224
216
  };
225
217
  export { handleClaudeRuntimeSwitch }; // Re-export from ./claude.runtime-switch.lib.mjs
226
-
227
218
  // Store Claude Code version globally (set during validation)
228
219
  let detectedClaudeVersion = null;
229
-
230
- /**
231
- * Get the detected Claude Code version
232
- * @returns {string|null} The detected version or null if not yet detected
233
- */
220
+ /** Get the detected Claude Code version @returns {string|null} */
234
221
  export const getClaudeVersion = () => detectedClaudeVersion;
235
-
236
- /**
237
- * Set the detected Claude Code version (called during validation)
238
- * @param {string} version - The detected version string
239
- */
222
+ /** Set the detected Claude Code version (called during validation) @param {string} version */
240
223
  export const setClaudeVersion = version => {
241
224
  detectedClaudeVersion = version;
242
225
  };
243
-
244
- /**
245
- * Resolve thinking settings based on --think and --thinking-budget options
246
- * Handles translation between thinking levels and token budgets based on Claude Code version
247
- * @param {Object} argv - Command line arguments
248
- * @param {Function} log - Logging function
249
- * @returns {Object} { thinkingBudget, thinkLevel, translation, maxBudget } - Resolved settings
250
- */
226
+ /** Resolve thinking settings based on --think and --thinking-budget options */
251
227
  export const resolveThinkingSettings = async (argv, log) => {
252
228
  const minVersion = argv.thinkingBudgetClaudeMinimumVersion || '2.1.12';
253
229
  const version = detectedClaudeVersion || '0.0.0'; // Assume old version if not detected
254
230
  const isNewVersion = supportsThinkingBudget(version, minVersion);
255
-
256
231
  // Get max thinking budget from argv or use default (see issue #1146)
257
232
  const maxBudget = argv.maxThinkingBudget ?? DEFAULT_MAX_THINKING_BUDGET;
258
-
259
233
  // Get thinking level mappings calculated from maxBudget
260
234
  const thinkingLevelToTokens = getThinkingLevelToTokens(maxBudget);
261
235
  const tokensToThinkingLevel = getTokensToThinkingLevel(maxBudget);
262
-
263
236
  let thinkingBudget = argv.thinkingBudget;
264
237
  let thinkLevel = argv.think;
265
238
  let translation = null;
266
-
267
239
  if (isNewVersion) {
268
240
  // Claude Code >= 2.1.12: translate --think to --thinking-budget
269
241
  if (thinkLevel !== undefined && thinkingBudget === undefined) {
@@ -290,45 +262,23 @@ export const resolveThinkingSettings = async (argv, log) => {
290
262
  thinkingBudget = undefined;
291
263
  }
292
264
  }
293
-
294
265
  return { thinkingBudget, thinkLevel, translation, isNewVersion, maxBudget };
295
266
  };
296
- /**
297
- * Check if Playwright MCP is available and connected to Claude
298
- * @returns {Promise<boolean>} True if Playwright MCP is available, false otherwise
299
- */
267
+ /** Check if Playwright MCP is available and connected to Claude @returns {Promise<boolean>} */
300
268
  export const checkPlaywrightMcpAvailability = async () => {
301
269
  try {
302
- // Try to run a simple claude command that would list MCP servers if available
303
- // Use a timeout to avoid hanging if Claude is not installed
304
270
  const result = await $`timeout 5 claude mcp list 2>&1`.catch(() => null);
305
-
306
- if (!result || result.code !== 0) {
307
- return false;
308
- }
309
-
271
+ if (!result || result.code !== 0) return false;
310
272
  const output = result.stdout?.toString() || '';
311
-
312
- // Check if playwright is in the list of MCP servers
313
- if (output.toLowerCase().includes('playwright')) {
314
- return true;
315
- }
316
-
273
+ if (output.toLowerCase().includes('playwright')) return true;
317
274
  return false;
318
275
  } catch {
319
- // If any error occurs, assume Playwright MCP is not available
320
276
  return false;
321
277
  }
322
278
  };
323
- /**
324
- * Execute Claude with all prompts and settings
325
- * This is the main entry point that handles all prompt building and execution
326
- * @param {Object} params - Parameters for Claude execution
327
- * @returns {Object} Result of the execution including success status and session info
328
- */
279
+ /** Execute Claude with all prompts and settings - main entry point */
329
280
  export const executeClaude = async params => {
330
281
  const { issueUrl, issueNumber, prNumber, prUrl, branchName, tempDir, workspaceTmpDir, isContinueMode, mergeStateStatus, forkedRepo, feedbackLines, forkActionsUrl, owner, repo, argv, log, setLogFile, getLogFile, formatAligned, getResourceSnapshot, claudePath, $ } = params;
331
-
332
282
  // Check if agent-commander is installed when the option is enabled
333
283
  if (argv.promptSubagentsViaAgentCommander) {
334
284
  try {
@@ -339,17 +289,14 @@ export const executeClaude = async params => {
339
289
  await log('āš ļø agent-commander not installed; prompt guidance will be skipped (npm i -g @link-assistant/agent-commander)');
340
290
  }
341
291
  }
342
-
343
292
  // Import prompt building functions from claude.prompts.lib.mjs
344
293
  const { buildUserPrompt, buildSystemPrompt } = await import('./claude.prompts.lib.mjs');
345
-
346
294
  // Check if the model supports vision using models.dev API
347
295
  const mappedModel = mapModelToId(argv.model);
348
296
  const modelSupportsVision = await checkModelVisionCapability(mappedModel);
349
297
  if (argv.verbose) {
350
298
  await log(`šŸ‘ļø Model vision capability: ${modelSupportsVision ? 'supported' : 'not supported'}`, { verbose: true });
351
299
  }
352
-
353
300
  // Build the user prompt
354
301
  const prompt = buildUserPrompt({
355
302
  issueUrl,
@@ -489,34 +436,18 @@ export const fetchModelInfo = async modelId => {
489
436
  return null;
490
437
  }
491
438
  };
492
-
493
- /**
494
- * Check if a model supports vision (image input) using models.dev API
495
- * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
496
- * @returns {Promise<boolean>} True if the model supports vision, false otherwise
497
- */
439
+ /** Check if a model supports vision (image input) using models.dev API @returns {Promise<boolean>} */
498
440
  export const checkModelVisionCapability = async modelId => {
499
441
  try {
500
442
  const modelInfo = await fetchModelInfo(modelId);
501
- if (!modelInfo) {
502
- return false;
503
- }
504
- // Check if 'image' is in the input modalities
443
+ if (!modelInfo) return false;
505
444
  const inputModalities = modelInfo.modalities?.input || [];
506
445
  return inputModalities.includes('image');
507
446
  } catch {
508
- // If we can't determine vision capability, default to false
509
447
  return false;
510
448
  }
511
449
  };
512
-
513
- /**
514
- * Calculate USD cost for a model's usage with detailed breakdown
515
- * @param {Object} usage - Token usage object
516
- * @param {Object} modelInfo - Model information from pricing API
517
- * @param {boolean} includeBreakdown - Whether to include detailed calculation breakdown
518
- * @returns {Object} Cost data with optional breakdown
519
- */
450
+ /** Calculate USD cost for a model's usage with detailed breakdown */
520
451
  export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) => {
521
452
  if (!modelInfo || !modelInfo.cost) {
522
453
  return includeBreakdown ? { total: 0, breakdown: null } : 0;
@@ -862,26 +793,16 @@ export const executeClaudeCommand = async params => {
862
793
  let anthropicTotalCostUSD = null; // Capture Anthropic's official total_cost_usd from result
863
794
  let errorDuringExecution = false; // Issue #1088: Track if error_during_execution subtype occurred
864
795
  let resultSummary = null; // Issue #1263: Capture AI result summary for --attach-solution-summary
865
-
866
796
  // Create interactive mode handler if enabled
867
797
  let interactiveHandler = null;
868
798
  if (argv.interactiveMode && owner && repo && prNumber) {
869
799
  await log('šŸ”Œ Interactive mode: Creating handler for real-time PR comments', { verbose: true });
870
- interactiveHandler = createInteractiveHandler({
871
- owner,
872
- repo,
873
- prNumber,
874
- $,
875
- log,
876
- verbose: argv.verbose,
877
- });
800
+ interactiveHandler = createInteractiveHandler({ owner, repo, prNumber, $, log, verbose: argv.verbose });
878
801
  } else if (argv.interactiveMode) {
879
802
  await log('āš ļø Interactive mode: Disabled - missing PR info (owner/repo/prNumber)', { verbose: true });
880
803
  }
881
-
882
804
  // Build claude command with optional resume flag
883
805
  let execCommand;
884
- // Map model alias to full ID
885
806
  const mappedModel = mapModelToId(argv.model);
886
807
  // Build claude command arguments
887
808
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${mappedModel}`;
@@ -890,13 +811,10 @@ export const executeClaudeCommand = async params => {
890
811
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
891
812
  }
892
813
  claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
893
- // Build the full command for display (with jq for formatting as in v0.3.2)
894
814
  const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
895
- // Print the actual raw command being executed
896
815
  await log(`\n${formatAligned('šŸ“', 'Raw command:', '')}`);
897
816
  await log(`${fullCommand}`);
898
817
  await log('');
899
- // Output prompts in verbose mode for debugging
900
818
  if (argv.verbose) {
901
819
  await log('šŸ“‹ User prompt:', { verbose: true });
902
820
  await log('---BEGIN USER PROMPT---', { verbose: true });
@@ -908,10 +826,8 @@ export const executeClaudeCommand = async params => {
908
826
  await log('---END SYSTEM PROMPT---', { verbose: true });
909
827
  }
910
828
  try {
911
- // Resolve thinking settings (handles translation between --think and --thinking-budget based on Claude version)
912
- // See issue #1146 for details on thinking budget translation
829
+ // Resolve thinking settings (see issue #1146)
913
830
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
914
-
915
831
  // Set CLAUDE_CODE_MAX_OUTPUT_TOKENS (see issue #1076), MAX_THINKING_TOKENS (see issue #1146),
916
832
  // MCP timeout configurations (see issue #1066), and CLAUDE_CODE_EFFORT_LEVEL for Opus 4.6 (Issue #1238)
917
833
  // Pass model for model-specific max output tokens (Issue #1221)
@@ -947,7 +863,45 @@ export const executeClaudeCommand = async params => {
947
863
  // Issue #1183: Line buffer for NDJSON stream parsing - accumulate incomplete lines across chunks
948
864
  // Long JSON messages (e.g., result with total_cost_usd) may be split across multiple stdout chunks
949
865
  let stdoutLineBuffer = '';
866
+ // Issue #1280: Track result event and timeout for hung processes
867
+ // Root cause: command-stream's stream() async iterator waits for BOTH process exit AND
868
+ // stdout/stderr pipe close before emitting 'end'. If the CLI process keeps stdout open after
869
+ // sending the result event, pumpReadable() hangs → finish() never fires → stream never ends.
870
+ // Additionally, command-stream v0.9.4 does NOT yield {type:'exit'} chunks from stream(),
871
+ // so the exit code detection via chunk.type==='exit' below is dead code.
872
+ // Workaround: after receiving the result event, start a timeout to force-kill the process.
873
+ // See: https://github.com/link-foundation/command-stream/issues/155
874
+ let resultEventReceived = false;
875
+ let resultTimeoutId = null;
876
+ let forceExitTriggered = false;
877
+ const streamCloseTimeoutMs = timeouts.resultStreamCloseMs;
878
+ const forceExitOnTimeout = async () => {
879
+ if (forceExitTriggered) return;
880
+ forceExitTriggered = true;
881
+ const elapsed = `${streamCloseTimeoutMs / 1000}s`;
882
+ await log(`āš ļø Stream didn't close ${elapsed} after result event, forcing exit (Issue #1280)`, { verbose: true });
883
+ await log(` command-stream stream() is likely stuck waiting for pipe close`, { verbose: true });
884
+ try {
885
+ if (execCommand.kill) {
886
+ await log(` Sending SIGTERM to process...`, { verbose: true });
887
+ execCommand.kill('SIGTERM');
888
+ setTimeout(() => {
889
+ try {
890
+ if (!execCommand.result?.code) {
891
+ log(` Process still alive after 2s, sending SIGKILL`, { verbose: true });
892
+ execCommand.kill('SIGKILL');
893
+ }
894
+ } catch {
895
+ /* process may have exited */
896
+ }
897
+ }, 2000);
898
+ }
899
+ } catch (e) {
900
+ await log(` Warning: Could not kill process: ${e.message}`, { verbose: true });
901
+ }
902
+ };
950
903
  for await (const chunk of execCommand.stream()) {
904
+ if (forceExitTriggered) break;
951
905
  if (chunk.type === 'stdout') {
952
906
  const output = chunk.data.toString();
953
907
  // Append to buffer and split; keep last element (may be incomplete) for next chunk
@@ -990,10 +944,14 @@ export const executeClaudeCommand = async params => {
990
944
  toolUseCount++;
991
945
  }
992
946
  // Handle session result type from Claude CLI (emitted when session completes)
993
- // Subtypes: "success", "error_during_execution" (work may have been done), etc.
994
947
  if (data.type === 'result') {
948
+ // Issue #1280: Start 30s timeout for stream close after result event
949
+ if (!resultEventReceived) {
950
+ resultEventReceived = true;
951
+ await log(`šŸ“Œ Result event received, starting ${streamCloseTimeoutMs / 1000}s stream close timeout (Issue #1280)`, { verbose: true });
952
+ resultTimeoutId = setTimeout(forceExitOnTimeout, streamCloseTimeoutMs);
953
+ }
995
954
  // Issue #1104: Only extract cost from subtype 'success' results
996
- // This is explicit and reliable - error_during_execution results have zero cost
997
955
  if (data.subtype === 'success' && data.total_cost_usd !== undefined && data.total_cost_usd !== null) {
998
956
  anthropicTotalCostUSD = data.total_cost_usd;
999
957
  await log(`šŸ’° Anthropic official cost captured from success result: $${anthropicTotalCostUSD.toFixed(6)}`, { verbose: true });
@@ -1096,11 +1054,13 @@ export const executeClaudeCommand = async params => {
1096
1054
  }
1097
1055
  }
1098
1056
  } else if (chunk.type === 'exit') {
1057
+ // Note: command-stream v0.9.4 stream() does NOT yield exit chunks (Issue #1280).
1058
+ // Exit code is obtained from execCommand.result.code after the loop.
1059
+ // This branch is kept for forward-compatibility if command-stream adds exit chunks.
1099
1060
  exitCode = chunk.code;
1100
1061
  if (chunk.code !== 0) {
1101
1062
  commandFailed = true;
1102
1063
  }
1103
- // Don't break here - let the loop finish naturally to process all output
1104
1064
  }
1105
1065
  }
1106
1066
 
@@ -1116,7 +1076,15 @@ export const executeClaudeCommand = async params => {
1116
1076
  if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'raw' });
1117
1077
  }
1118
1078
  }
1119
-
1079
+ // Issue #1280: Clear the stream close timeout since we exited the loop
1080
+ if (resultTimeoutId) {
1081
+ clearTimeout(resultTimeoutId);
1082
+ if (forceExitTriggered) {
1083
+ await log('āš ļø Stream exited via force-kill timeout (Issue #1280)', { verbose: true });
1084
+ } else {
1085
+ await log('āœ… Stream closed normally after result event', { verbose: true });
1086
+ }
1087
+ }
1120
1088
  // Issue #1165: Check actual exit code from command result for more reliable detection
1121
1089
  // The .stream() method may not emit 'exit' chunks, but the command object still tracks the exit code
1122
1090
  // Exit code 127 is the standard Unix convention for "command not found"
@@ -49,6 +49,9 @@ export const timeouts = {
49
49
  githubRepoDelay: parseIntWithDefault('HIVE_MIND_GITHUB_REPO_DELAY_MS', 2000),
50
50
  retryBaseDelay: parseIntWithDefault('HIVE_MIND_RETRY_BASE_DELAY_MS', 5000),
51
51
  retryBackoffDelay: parseIntWithDefault('HIVE_MIND_RETRY_BACKOFF_DELAY_MS', 1000),
52
+ // Issue #1280: Timeout (ms) to wait for stream close after result event before force-killing
53
+ // command-stream's stream() waits for process exit + pipe close; if stdout stays open, it hangs
54
+ resultStreamCloseMs: parseIntWithDefault('HIVE_MIND_RESULT_STREAM_CLOSE_MS', 30000),
52
55
  };
53
56
 
54
57
  // Auto-continue configurations