@link-assistant/hive-mind 1.50.5 → 1.50.7

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,23 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.50.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 84b9853: fix: make all long sleeps interruptible so CTRL+C responds immediately (#1574)
8
+ - Replace raw `setTimeout` sleeps with an interruptible sleep utility that listens for SIGINT
9
+ - Ensure CTRL+C during CI polling, auto-merge waits, and auto-continue delays terminates the process immediately
10
+ - Add `interruptible-sleep.lib.mjs` with full test coverage
11
+
12
+ ## 1.50.6
13
+
14
+ ### Patch Changes
15
+
16
+ - 854a74b: feat: track sub-agent calls and show per-call stats in budget display (#1590)
17
+ - Split budget usage statistics per sub-agent call when working sessions contain multiple sub-agent invocations
18
+ - Extract and display individual sub-agent call metrics from Claude API session data
19
+ - Add budget stats library for parsing and formatting per-call usage information
20
+
3
21
  ## 1.50.5
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.50.5",
3
+ "version": "1.50.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -362,11 +362,71 @@ const formatContextOutputLine = (peakContext, contextLimit, outputTokens, output
362
362
  * @param {Object} tokenUsage - Token usage data from calculateSessionTokens or buildAgentBudgetStats
363
363
  * @returns {string} Formatted markdown string for PR comment
364
364
  */
365
- export const buildBudgetStatsString = tokenUsage => {
365
+ /**
366
+ * Issue #1590: Build a map of model short name to sub-agent call count.
367
+ * Sub-agent calls use short model names (e.g., "sonnet", "haiku", "opus")
368
+ * while modelUsage uses full model IDs (e.g., "claude-sonnet-4-6").
369
+ * @param {Array|null} subAgentCalls - Array of {id, description, model} from stream tracking
370
+ * @returns {Object} Map of model short name to call count, e.g., {"sonnet": 12, "haiku": 3}
371
+ */
372
+ const buildSubAgentCallCounts = subAgentCalls => {
373
+ if (!subAgentCalls || subAgentCalls.length === 0) return {};
374
+ const counts = {};
375
+ for (const call of subAgentCalls) {
376
+ const model = call.model || 'default';
377
+ counts[model] = (counts[model] || 0) + 1;
378
+ }
379
+ return counts;
380
+ };
381
+
382
+ /**
383
+ * Issue #1590: Match a full model ID to sub-agent call count.
384
+ * Maps full model IDs (e.g., "claude-sonnet-4-6") to short names used in Agent tool
385
+ * (e.g., "sonnet") and returns the call count.
386
+ * @param {string} modelId - Full model ID
387
+ * @param {Object} callCounts - Map from buildSubAgentCallCounts
388
+ * @returns {number} Number of sub-agent calls for this model, or 0 if none
389
+ */
390
+ const getSubAgentCallCount = (modelId, callCounts) => {
391
+ if (!callCounts || Object.keys(callCounts).length === 0) return 0;
392
+ // Direct match first (e.g., model short name used as full ID)
393
+ if (callCounts[modelId]) return callCounts[modelId];
394
+ // Match short names to full model IDs:
395
+ // "claude-sonnet-4-6" contains "sonnet", "claude-haiku-4-5-20251001" contains "haiku", etc.
396
+ const modelIdLower = modelId.toLowerCase();
397
+ for (const [shortName, count] of Object.entries(callCounts)) {
398
+ if (modelIdLower.includes(shortName.toLowerCase())) return count;
399
+ }
400
+ return 0;
401
+ };
402
+
403
+ /**
404
+ * Issue #1590: Get sub-agent calls matching a specific model ID.
405
+ * Filters the subAgentCalls array to return only calls whose short model name
406
+ * matches the given full model ID.
407
+ * @param {string} modelId - Full model ID (e.g., "claude-sonnet-4-6")
408
+ * @param {Array|null} subAgentCalls - Array of {id, description, model} from stream tracking
409
+ * @returns {Array} Matching sub-agent calls for this model
410
+ */
411
+ const getSubAgentCallsForModel = (modelId, subAgentCalls) => {
412
+ if (!subAgentCalls || subAgentCalls.length === 0) return [];
413
+ const modelIdLower = modelId.toLowerCase();
414
+ return subAgentCalls.filter(call => {
415
+ const shortName = (call.model || 'default').toLowerCase();
416
+ return modelIdLower === shortName || modelIdLower.includes(shortName);
417
+ });
418
+ };
419
+
420
+ export const buildBudgetStatsString = (tokenUsage, subAgentCalls = null) => {
366
421
  if (!tokenUsage) return '';
367
422
 
368
423
  let stats = '\n\n### šŸ“Š **Context and tokens usage:**';
369
424
 
425
+ // Issue #1590: Build sub-agent call counts per model for per-call breakdown
426
+ // Guard: subAgentCalls must be an array (ignore legacy streamUsage objects passed as second arg)
427
+ const validSubAgentCalls = Array.isArray(subAgentCalls) ? subAgentCalls : null;
428
+ const subAgentCallCounts = buildSubAgentCallCounts(validSubAgentCalls);
429
+
370
430
  // Per-model breakdown
371
431
  if (tokenUsage.modelUsage) {
372
432
  const modelIds = Object.keys(tokenUsage.modelUsage);
@@ -383,7 +443,17 @@ export const buildBudgetStatsString = tokenUsage => {
383
443
  const contextLimit = usage.modelInfo?.limit?.context;
384
444
  const outputLimit = usage.modelInfo?.limit?.output;
385
445
 
386
- if (isMultiModel) stats += `\n\n**${modelName}:**`;
446
+ // Issue #1590: Check if this model was used as a sub-agent
447
+ const callCount = getSubAgentCallCount(modelId, subAgentCallCounts);
448
+
449
+ if (isMultiModel) {
450
+ // Issue #1590: Show sub-agent call count alongside model name
451
+ if (callCount > 1) {
452
+ stats += `\n\n**${modelName}:** (${callCount} sub-agent calls)`;
453
+ } else {
454
+ stats += `\n\n**${modelName}:**`;
455
+ }
456
+ }
387
457
 
388
458
  const peakContext = usage.peakContextUsage || 0;
389
459
 
@@ -410,9 +480,16 @@ export const buildBudgetStatsString = tokenUsage => {
410
480
  }
411
481
 
412
482
  // Issue #1547: Consistent output format — use X / Y (Z%) output tokens when limit known
483
+ // Issue #1590: When multiple sub-agent calls exist, show total output without misleading
484
+ // per-call percentage (e.g., 530% is sum across 12 calls, not a single call)
413
485
  if (peakContext === 0 && outputLimit) {
414
- const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
415
- totalLine += `, ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
486
+ if (callCount > 1) {
487
+ // Show total output without percentage (percentage is misleading for aggregated sub-agent calls)
488
+ totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
489
+ } else {
490
+ const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
491
+ totalLine += `, ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`;
492
+ }
416
493
  } else {
417
494
  totalLine += `, ${formatTokensCompact(usage.outputTokens)} output tokens`;
418
495
  }
@@ -422,6 +499,61 @@ export const buildBudgetStatsString = tokenUsage => {
422
499
  totalLine += `, $${usage.costUSD.toFixed(6)} cost`;
423
500
  }
424
501
 
502
+ // Issue #1590: Show individual sub-agent call list when multiple calls exist
503
+ // Total line appears AFTER the sub-agent calls list (not before)
504
+ if (callCount > 1) {
505
+ const matchingCalls = getSubAgentCallsForModel(modelId, validSubAgentCalls);
506
+ // Issue #1590: Check if actual per-call usage data is available from parent_tool_use_id tracking
507
+ const hasActualUsage = matchingCalls.some(c => c.usage && (c.usage.inputTokens > 0 || c.usage.outputTokens > 0 || c.usage.cacheReadTokens > 0 || c.usage.cacheCreationTokens > 0));
508
+
509
+ stats += `\n\nSub-agent calls:`;
510
+ if (hasActualUsage) {
511
+ // Show actual per-call usage with limits and percentages (same format as sub-sessions)
512
+ for (let i = 0; i < matchingCalls.length; i++) {
513
+ const call = matchingCalls[i];
514
+ const cu = call.usage || {};
515
+ const callInput = (cu.inputTokens || 0) + (cu.cacheCreationTokens || 0) + (cu.cacheReadTokens || 0);
516
+ const callOutput = cu.outputTokens || 0;
517
+ const parts = [];
518
+ if (contextLimit) {
519
+ const pct = ((callInput / contextLimit) * 100).toFixed(0);
520
+ parts.push(`${formatTokensCompact(callInput)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
521
+ } else {
522
+ parts.push(`${formatTokensCompact(callInput)} input tokens`);
523
+ }
524
+ if (outputLimit) {
525
+ const outPct = ((callOutput / outputLimit) * 100).toFixed(0);
526
+ parts.push(`${formatTokensCompact(callOutput)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`);
527
+ } else {
528
+ parts.push(`${formatTokensCompact(callOutput)} output tokens`);
529
+ }
530
+ stats += `\n${i + 1}. ${parts.join(', ')}`;
531
+ }
532
+ } else {
533
+ // Fallback: show estimates with limits and percentages when actual per-call data is not available
534
+ const avgInput = Math.round((totalInputNonCached + cachedTokens) / callCount);
535
+ const avgOutput = Math.round(usage.outputTokens / callCount);
536
+ for (let i = 0; i < matchingCalls.length; i++) {
537
+ const parts = [];
538
+ if (contextLimit) {
539
+ const pct = ((avgInput / contextLimit) * 100).toFixed(0);
540
+ parts.push(`~${formatTokensCompact(avgInput)} / ${formatTokensCompact(contextLimit)} (${pct}%) input tokens`);
541
+ } else {
542
+ parts.push(`~${formatTokensCompact(avgInput)} input tokens`);
543
+ }
544
+ if (outputLimit) {
545
+ const outPct = ((avgOutput / outputLimit) * 100).toFixed(0);
546
+ parts.push(`~${formatTokensCompact(avgOutput)} / ${formatTokensCompact(outputLimit)} (${outPct}%) output tokens`);
547
+ } else {
548
+ parts.push(`~${formatTokensCompact(avgOutput)} output tokens`);
549
+ }
550
+ stats += `\n${i + 1}. ${parts.join(', ')}`;
551
+ }
552
+ // Note about estimates only when using fallback
553
+ stats += `\n\n_Per-call values are estimates (total Ć· ${callCount}). Exact per-call breakdown requires [upstream support](https://github.com/anthropics/claude-code/issues/46520)._`;
554
+ }
555
+ }
556
+
425
557
  stats += `\n\nTotal: ${totalLine}`;
426
558
  }
427
559
  }
@@ -468,3 +600,36 @@ export const buildAgentBudgetStats = (tokenUsage, pricingInfo) => {
468
600
  totalTokens: tokenUsage.inputTokens + (tokenUsage.cacheWriteTokens || 0) + tokenUsage.outputTokens,
469
601
  };
470
602
  };
603
+
604
+ /**
605
+ * Issue #1590: Creates a fresh sub-agent call entry for tracking per-call token usage
606
+ * @param {Object} item - The tool_use content item from the assistant message
607
+ * @returns {Object} Sub-agent call entry with id, description, model, and empty usage
608
+ */
609
+ export const createSubAgentCallEntry = item => {
610
+ const agentInput = item.input || {};
611
+ return {
612
+ id: item.id || null,
613
+ description: agentInput.description || null,
614
+ model: agentInput.model || null,
615
+ usage: {
616
+ inputTokens: 0,
617
+ cacheCreationTokens: 0,
618
+ cacheReadTokens: 0,
619
+ outputTokens: 0,
620
+ totalTokens: null, // from task_notification
621
+ },
622
+ };
623
+ };
624
+
625
+ /**
626
+ * Issue #1590: Accumulates token usage from a stream event into a sub-agent call entry
627
+ * @param {Object} callEntry - The sub-agent call entry to accumulate into
628
+ * @param {Object} u - The usage object from the stream event
629
+ */
630
+ export const accumulateSubAgentUsage = (callEntry, u) => {
631
+ if (u.input_tokens) callEntry.usage.inputTokens += u.input_tokens;
632
+ if (u.cache_creation_input_tokens) callEntry.usage.cacheCreationTokens += u.cache_creation_input_tokens;
633
+ if (u.cache_read_input_tokens) callEntry.usage.cacheReadTokens += u.cache_read_input_tokens;
634
+ if (u.output_tokens) callEntry.usage.outputTokens += u.output_tokens;
635
+ };
@@ -13,7 +13,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
14
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
15
15
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
16
- import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
16
+ import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage, createSubAgentCallEntry, accumulateSubAgentUsage } from './claude.budget-stats.lib.mjs';
17
17
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
18
18
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
19
19
  import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
@@ -653,44 +653,9 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
653
653
  throw new Error(`Failed to read session file: ${readError.message}`);
654
654
  }
655
655
  };
656
- /**
657
- * Determines whether a stderr message line should be treated as an error.
658
- *
659
- * Excludes:
660
- * - Emoji-prefixed warnings (Issue #477): lines starting with āš ļø or ⚠
661
- * - JSON-structured log messages with non-error level (Issue #1337):
662
- * e.g. {"level":"warn","message":"...failed..."} — the word "failed" is in
663
- * the message text but the level is "warn", so it is NOT an error.
664
- * Only JSON lines with level "error" or "fatal" are treated as real errors.
665
- *
666
- * @param {string} message - A single trimmed stderr line
667
- * @returns {boolean} true if the line should count as an error
668
- */
669
- export const isStderrError = message => {
670
- const trimmed = message.trim();
671
- if (!trimmed) return false;
672
- // Detection 1: Emoji-prefixed warnings (Issue #477)
673
- let isWarning = trimmed.startsWith('āš ļø') || trimmed.startsWith('⚠');
674
- // Detection 2: JSON-structured log messages (Issue #1337)
675
- if (!isWarning && trimmed.startsWith('{')) {
676
- try {
677
- const parsed = JSON.parse(trimmed);
678
- if (parsed && typeof parsed.level === 'string') {
679
- const level = parsed.level.toLowerCase();
680
- // Only "error" and "fatal" levels are real errors.
681
- if (level !== 'error' && level !== 'fatal') {
682
- isWarning = true;
683
- }
684
- }
685
- } catch {
686
- // Not valid JSON — fall through to keyword matching
687
- }
688
- }
689
- if (!isWarning && (trimmed.includes('Error:') || trimmed.includes('error') || trimmed.includes('failed') || trimmed.includes('not found'))) {
690
- return true;
691
- }
692
- return false;
693
- };
656
+ // Extracted to claude.stderr.lib.mjs (Issue #477, #1337)
657
+ import { isStderrError } from './claude.stderr.lib.mjs';
658
+ export { isStderrError };
694
659
  export const executeClaudeCommand = async params => {
695
660
  const {
696
661
  tempDir,
@@ -777,6 +742,10 @@ export const executeClaudeCommand = async params => {
777
742
  let errorDuringExecution = false;
778
743
  let resultSummary = null;
779
744
  let resultModelUsage = null;
745
+ // Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
746
+ const subAgentCalls = [];
747
+ // Issue #1590: Map tool_use_id -> subAgentCalls index for accumulating per-call usage from parent_tool_use_id events
748
+ const subAgentCallsByToolUseId = new Map();
780
749
  // Issue #1491: Track token usage from stream JSON events for independent calculation
781
750
  const streamTokenUsage = {
782
751
  inputTokens: 0,
@@ -1026,6 +995,18 @@ export const executeClaudeCommand = async params => {
1026
995
  if (u.cache_read_input_tokens) streamTokenUsage.cacheReadTokens += u.cache_read_input_tokens;
1027
996
  if (u.output_tokens) streamTokenUsage.outputTokens += u.output_tokens;
1028
997
  streamTokenUsage.eventCount++;
998
+ // Issue #1590: Accumulate per-sub-agent usage from parent_tool_use_id
999
+ if (data.parent_tool_use_id && subAgentCallsByToolUseId.has(data.parent_tool_use_id)) {
1000
+ accumulateSubAgentUsage(subAgentCallsByToolUseId.get(data.parent_tool_use_id), u);
1001
+ }
1002
+ }
1003
+ // Issue #1590: Capture total_tokens from task_notification (completed sub-agent)
1004
+ if (data.type === 'system' && data.subtype === 'task_notification' && data.status === 'completed' && data.tool_use_id) {
1005
+ const callEntry = subAgentCallsByToolUseId.get(data.tool_use_id);
1006
+ if (callEntry && data.usage && data.usage.total_tokens) {
1007
+ callEntry.usage.totalTokens = data.usage.total_tokens;
1008
+ await log(`šŸ¤– Sub-agent "${callEntry.description || 'unknown'}" completed: ${data.usage.total_tokens} total tokens`, { verbose: true });
1009
+ }
1029
1010
  }
1030
1011
  if (data.type === 'assistant' && data.message && data.message.content) {
1031
1012
  const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
@@ -1054,6 +1035,13 @@ export const executeClaudeCommand = async params => {
1054
1035
  await log('ā±ļø Detected request timeout in assistant message (will retry with --resume)', { verbose: true });
1055
1036
  }
1056
1037
  }
1038
+ // Issue #1590: Track sub-agent calls (Agent tool invocations) for per-call stats
1039
+ if (item.type === 'tool_use' && item.name === 'Agent') {
1040
+ const callEntry = createSubAgentCallEntry(item);
1041
+ subAgentCalls.push(callEntry);
1042
+ if (item.id) subAgentCallsByToolUseId.set(item.id, callEntry);
1043
+ await log(`šŸ¤– Sub-agent call #${subAgentCalls.length}: "${callEntry.description || 'unknown'}" (model: ${callEntry.model || 'default'})`, { verbose: true });
1044
+ }
1057
1045
  }
1058
1046
  }
1059
1047
  } catch (parseError) {
@@ -1381,6 +1369,7 @@ export const executeClaudeCommand = async params => {
1381
1369
  resultSummary, // Issue #1263: Include result summary for --attach-solution-summary
1382
1370
  resultModelUsage, // Issue #1454
1383
1371
  streamTokenUsage: streamTokenUsage.eventCount > 0 ? streamTokenUsage : null, // Issue #1491
1372
+ subAgentCalls: subAgentCalls.length > 0 ? subAgentCalls : null, // Issue #1590
1384
1373
  };
1385
1374
  } catch (error) {
1386
1375
  reportError(error, {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Determines whether a stderr message line should be treated as an error.
3
+ *
4
+ * Excludes:
5
+ * - Emoji-prefixed warnings (Issue #477): lines starting with āš ļø or ⚠
6
+ * - JSON-structured log messages with non-error level (Issue #1337):
7
+ * e.g. {"level":"warn","message":"...failed..."} — the word "failed" is in
8
+ * the message text but the level is "warn", so it is NOT an error.
9
+ * Only JSON lines with level "error" or "fatal" are treated as real errors.
10
+ *
11
+ * @param {string} message - A single trimmed stderr line
12
+ * @returns {boolean} true if the line should count as an error
13
+ */
14
+ export const isStderrError = message => {
15
+ const trimmed = message.trim();
16
+ if (!trimmed) return false;
17
+ // Detection 1: Emoji-prefixed warnings (Issue #477)
18
+ let isWarning = trimmed.startsWith('āš ļø') || trimmed.startsWith('⚠');
19
+ // Detection 2: JSON-structured log messages (Issue #1337)
20
+ if (!isWarning && trimmed.startsWith('{')) {
21
+ try {
22
+ const parsed = JSON.parse(trimmed);
23
+ if (parsed && typeof parsed.level === 'string') {
24
+ const level = parsed.level.toLowerCase();
25
+ // Only "error" and "fatal" levels are real errors.
26
+ if (level !== 'error' && level !== 'fatal') {
27
+ isWarning = true;
28
+ }
29
+ }
30
+ } catch {
31
+ // Not valid JSON — fall through to keyword matching
32
+ }
33
+ }
34
+ if (!isWarning && (trimmed.includes('Error:') || trimmed.includes('error') || trimmed.includes('failed') || trimmed.includes('not found'))) {
35
+ return true;
36
+ }
37
+ return false;
38
+ };
@@ -366,7 +366,7 @@ export async function attachLogToGitHub(options) {
366
366
  resultModelUsage = null, // Issue #1454
367
367
  budgetStatsData = null, // Issue #1491: budget stats for comment
368
368
  } = options;
369
- const budgetStats = budgetStatsData ? buildBudgetStatsString(budgetStatsData.tokenUsage) : '';
369
+ const budgetStats = budgetStatsData ? buildBudgetStatsString(budgetStatsData.tokenUsage, budgetStatsData.subAgentCalls) : '';
370
370
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
371
371
  const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
372
372
  try {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Interruptible sleep utility for long-running wait loops.
3
+ *
4
+ * Replaces raw `await new Promise(r => setTimeout(r, ms))` with a sleep
5
+ * that resolves immediately on SIGINT, so the process exit handler chain
6
+ * is not blocked by a lingering timer.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1574
9
+ */
10
+
11
+ /**
12
+ * Sleep for `ms` milliseconds, but resolve early if SIGINT is received.
13
+ *
14
+ * When SIGINT fires during the sleep, the timer is cleared and the promise
15
+ * resolves with `{ interrupted: true }`. The existing SIGINT handler (from
16
+ * exit-handler.lib.mjs) continues to run normally — this function does NOT
17
+ * consume or re-emit the signal, it only ensures its own timer doesn't
18
+ * block the event loop.
19
+ *
20
+ * @param {number} ms - Duration in milliseconds
21
+ * @returns {Promise<{interrupted: boolean}>}
22
+ */
23
+ export function interruptibleSleep(ms) {
24
+ return new Promise(resolve => {
25
+ let timer;
26
+
27
+ const onInterrupt = () => {
28
+ clearTimeout(timer);
29
+ process.removeListener('SIGINT', onInterrupt);
30
+ resolve({ interrupted: true });
31
+ };
32
+
33
+ timer = setTimeout(() => {
34
+ process.removeListener('SIGINT', onInterrupt);
35
+ resolve({ interrupted: false });
36
+ }, ms);
37
+
38
+ process.on('SIGINT', onInterrupt);
39
+ });
40
+ }
41
+
42
+ export default { interruptibleSleep };
@@ -49,6 +49,9 @@ const { extractLinkedIssueNumber } = githubLinking;
49
49
  // Import configuration
50
50
  import { autoContinue, limitReset } from './config.lib.mjs';
51
51
 
52
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
53
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
54
+
52
55
  const { calculateWaitTime } = validation;
53
56
 
54
57
  /**
@@ -116,7 +119,7 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
116
119
  }, countdownInterval);
117
120
 
118
121
  // Wait until reset time
119
- await new Promise(resolve => setTimeout(resolve, waitMs));
122
+ await interruptibleSleep(waitMs);
120
123
  clearInterval(countdownTimer);
121
124
 
122
125
  const actionType = isRestart ? 'Restarting' : 'Resuming';
@@ -54,6 +54,9 @@ import { limitReset } from './config.lib.mjs';
54
54
  const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
55
55
  const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
56
56
 
57
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
58
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
59
+
57
60
  /**
58
61
  * Main function: Watch and restart until PR becomes mergeable
59
62
  * This implements --auto-restart-until-mergeable functionality
@@ -104,7 +107,7 @@ export const watchUntilMergeable = async params => {
104
107
  // Issue #1567: Wait for initial cooldown before first check.
105
108
  // This gives CI/CD time to start and solution logs time to be posted.
106
109
  await log(formatAligned('ā³', 'Initial cooldown:', `Waiting ${INITIAL_COOLDOWN_SECONDS}s before first check...`));
107
- await new Promise(resolve => setTimeout(resolve, INITIAL_COOLDOWN_SECONDS * 1000));
110
+ await interruptibleSleep(INITIAL_COOLDOWN_SECONDS * 1000);
108
111
  await log(formatAligned('āœ…', 'Cooldown complete:', 'Starting monitoring loop'));
109
112
  await log('');
110
113
 
@@ -200,7 +203,7 @@ export const watchUntilMergeable = async params => {
200
203
  if (!noCiConfigured) {
201
204
  const DOUBLE_CHECK_DELAY_MS = 10000; // 10 seconds
202
205
  await log(formatAligned('šŸ”', 'Multi-mechanism CI consensus check:', `Waiting ${DOUBLE_CHECK_DELAY_MS / 1000}s then verifying...`, 2));
203
- await new Promise(resolve => setTimeout(resolve, DOUBLE_CHECK_DELAY_MS));
206
+ await interruptibleSleep(DOUBLE_CHECK_DELAY_MS);
204
207
 
205
208
  // Run multi-mechanism consensus: Check Runs API + Workflow Runs API + Repo-wide actions
206
209
  const consensus = await checkCIConsensus({
@@ -223,7 +226,7 @@ export const watchUntilMergeable = async params => {
223
226
  const actualWaitSeconds = currentBackoffSeconds;
224
227
  await log(formatAligned('ā±ļø', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
225
228
  await log('');
226
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
229
+ await interruptibleSleep(actualWaitSeconds * 1000);
227
230
  continue;
228
231
  }
229
232
  await log(formatAligned('āœ…', 'All CI mechanisms agree:', `CheckRuns=${consensus.mechanisms.checkRunsAPI.status}, WorkflowRuns=complete(${consensus.mechanisms.workflowRunsAPI.total}), RepoActions=${consensus.mechanisms.repoActions.skipped ? 'skipped' : 'clear'}`, 2));
@@ -236,7 +239,7 @@ export const watchUntilMergeable = async params => {
236
239
  const actualWaitSeconds = currentBackoffSeconds;
237
240
  await log(formatAligned('ā±ļø', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
238
241
  await log('');
239
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
242
+ await interruptibleSleep(actualWaitSeconds * 1000);
240
243
  continue;
241
244
  }
242
245
  }
@@ -606,7 +609,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
606
609
  }
607
610
 
608
611
  // Wait until the limit resets
609
- await new Promise(resolve => setTimeout(resolve, waitMs));
612
+ await interruptibleSleep(waitMs);
610
613
 
611
614
  await log(formatAligned('āœ…', 'Usage limit wait complete', 'Resuming session...'));
612
615
  await log('');
@@ -841,7 +844,7 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
841
844
  const actualWaitSeconds = currentBackoffSeconds;
842
845
  await log(formatAligned('ā±ļø', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
843
846
  await log('');
844
- await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
847
+ await interruptibleSleep(actualWaitSeconds * 1000);
845
848
  }
846
849
  };
847
850
 
package/src/solve.mjs CHANGED
@@ -879,6 +879,7 @@ try {
879
879
  let resultSummary = toolResult.resultSummary || null;
880
880
  let resultModelUsage = toolResult.resultModelUsage || null;
881
881
  let streamTokenUsage = toolResult.streamTokenUsage || null;
882
+ let subAgentCalls = toolResult.subAgentCalls || null; // Issue #1590
882
883
  limitReached = toolResult.limitReached;
883
884
  cleanupContext.limitReached = limitReached;
884
885
 
@@ -1173,9 +1174,8 @@ try {
1173
1174
  // Show summary of session and log file
1174
1175
  await showSessionSummary(sessionId, limitReached, argv, issueUrl, tempDir, shouldAttachLogs);
1175
1176
 
1176
- // Issue #1571: Defense-in-depth guard. autoContinueWhenLimitResets() awaits child exit
1177
- // and calls process.exit(), so this should not be reached. Skip post-processing to
1178
- // prevent "Solution Draft Log" / "Ready to merge" comments before "Auto Resume".
1177
+ // Issue #1571: Defense-in-depth guard — skip post-processing if auto-continue is handling it
1178
+ // (prevents "Solution Draft Log" / "Ready to merge" comments before "Auto Resume")
1179
1179
  if (limitReached && (argv.autoResumeOnLimitReset || argv.autoRestartOnLimitReset) && global.limitResetTime) {
1180
1180
  await safeExit(0, 'Auto-continue child process will handle post-processing');
1181
1181
  }
@@ -1216,7 +1216,7 @@ try {
1216
1216
  }
1217
1217
 
1218
1218
  // Search for newly created pull requests and comments
1219
- const verifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage);
1219
+ const verifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage, subAgentCalls);
1220
1220
  const logsAlreadyUploaded = verifyResult?.logUploadSuccess || false;
1221
1221
 
1222
1222
  // Issue #1162: Auto-restart when PR title/description still has placeholder content
@@ -1263,7 +1263,7 @@ try {
1263
1263
  await cleanupClaudeFile(tempDir, branchName, null, argv);
1264
1264
 
1265
1265
  // Re-verify results after restart (without auto-restart flag to prevent recursion)
1266
- const reVerifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, { ...argv, autoRestartOnNonUpdatedPullRequestDescription: false }, shouldAttachLogs, false, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage);
1266
+ const reVerifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, { ...argv, autoRestartOnNonUpdatedPullRequestDescription: false }, shouldAttachLogs, false, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage, subAgentCalls);
1267
1267
 
1268
1268
  if (reVerifyResult?.prTitleHasPlaceholder || reVerifyResult?.prBodyHasPlaceholder) {
1269
1269
  await log('āš ļø PR title/description still not updated after restart');
@@ -503,7 +503,7 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
503
503
  };
504
504
 
505
505
  // Verify results by searching for new PRs and comments
506
- export const verifyResults = async (owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart = false, sessionId = null, tempDir = null, anthropicTotalCostUSD = null, publicPricingEstimate = null, pricingInfo = null, errorDuringExecution = false, sessionType = 'new', resultModelUsage = null, streamTokenUsage = null) => {
506
+ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart = false, sessionId = null, tempDir = null, anthropicTotalCostUSD = null, publicPricingEstimate = null, pricingInfo = null, errorDuringExecution = false, sessionType = 'new', resultModelUsage = null, streamTokenUsage = null, subAgentCalls = null) => {
507
507
  await log('\nšŸ” Searching for created pull requests or comments...');
508
508
 
509
509
  // Issue #1491, #1526: Build budget stats data for GitHub comment (computed once, used in both PR and issue paths)
@@ -513,7 +513,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
513
513
  const { calculateSessionTokens } = await import('./claude.lib.mjs');
514
514
  const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
515
515
  if (tokenUsage) {
516
- budgetStatsData = { tokenUsage, streamTokenUsage };
516
+ budgetStatsData = { tokenUsage, streamTokenUsage, subAgentCalls };
517
517
  }
518
518
  } catch (budgetError) {
519
519
  if (argv.verbose) await log(` āš ļø Could not calculate budget stats: ${budgetError.message}`, { verbose: true });
@@ -37,6 +37,9 @@ const { detectAndCountFeedback } = feedbackLib;
37
37
  const restartShared = await import('./solve.restart-shared.lib.mjs');
38
38
  const { checkPRMerged, checkForUncommittedChanges, getUncommittedChangesDetails, executeToolIteration, buildUncommittedChangesFeedback, isApiError } = restartShared;
39
39
 
40
+ // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
41
+ const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
42
+
40
43
  /**
41
44
  * Monitor for feedback in a loop and trigger restart when detected
42
45
  */
@@ -347,7 +350,7 @@ export const watchForFeedback = async params => {
347
350
  const { calculateSessionTokens } = await import('./claude.lib.mjs');
348
351
  const tokenUsage = await calculateSessionTokens(latestSessionId, tempDir, toolResult.resultModelUsage);
349
352
  if (tokenUsage) {
350
- autoRestartBudgetStatsData = { tokenUsage, streamTokenUsage: toolResult.streamTokenUsage || null };
353
+ autoRestartBudgetStatsData = { tokenUsage, streamTokenUsage: toolResult.streamTokenUsage || null, subAgentCalls: toolResult.subAgentCalls || null };
351
354
  }
352
355
  } catch (budgetError) {
353
356
  if (argv.verbose) await log(` āš ļø Could not calculate budget stats: ${budgetError.message}`, { verbose: true });
@@ -446,7 +449,7 @@ export const watchForFeedback = async params => {
446
449
  const actualWaitMs = actualWaitSeconds * 1000;
447
450
  await log(formatAligned('ā±ļø', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
448
451
  await log(''); // Blank line for readability
449
- await new Promise(resolve => setTimeout(resolve, actualWaitMs));
452
+ await interruptibleSleep(actualWaitMs);
450
453
  } else if (isTemporaryWatch && !firstIterationInTemporaryMode) {
451
454
  // In auto-restart mode, check immediately without waiting
452
455
  await log(formatAligned('', 'Checking immediately for uncommitted changes...', '', 2));