@link-assistant/hive-mind 1.56.6 → 1.56.8

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/src/solve.mjs CHANGED
@@ -32,17 +32,13 @@ const results = await import('./solve.results.lib.mjs');
32
32
  const { cleanupClaudeFile, showSessionSummary, verifyResults, buildClaudeResumeCommand, buildSolveResumeCommand, checkForAiCreatedComments, attachSolutionSummary, verifyPullRequestIssueLinkAfterAutoRestart } = results;
33
33
  const claudeLib = await import('./claude.lib.mjs');
34
34
  const { executeClaude, checkPlaywrightMcpAvailability } = claudeLib;
35
-
36
35
  const githubLinking = await import('./github-linking.lib.mjs');
37
36
  const { extractLinkedIssueNumber } = githubLinking;
38
-
39
37
  const usageLimitLib = await import('./usage-limit.lib.mjs');
40
38
  const { formatResetTimeWithRelative } = usageLimitLib;
41
-
42
39
  const errorHandlers = await import('./solve.error-handlers.lib.mjs');
43
40
  const { createUncaughtExceptionHandler, createUnhandledRejectionHandler, handleMainExecutionError, handleNoPrAvailableError } = errorHandlers;
44
41
  const { notifyIssueAboutPrePullRequestFailure } = await import('./solve.pre-pr-failure-notifier.lib.mjs');
45
-
46
42
  const watchLib = await import('./solve.watch.lib.mjs');
47
43
  const { startWatchMode } = watchLib;
48
44
  const { startAutoRestartUntilMergeable } = await import('./solve.auto-merge.lib.mjs');
@@ -62,7 +58,6 @@ const { postTrackedComment, USAGE_LIMIT_REACHED_MARKER } = await import('./tool-
62
58
  const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions } = await import('./solve.preparation.lib.mjs');
63
59
  const { validateAndExitOnInvalidModel } = await import('./models/index.mjs');
64
60
  const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
65
-
66
61
  // Initialize log file early (before argument parsing) to capture all output
67
62
  const logFile = await initializeLogFile(null);
68
63
  // Log version and raw command IMMEDIATELY after log file initialization
@@ -183,6 +178,8 @@ if (!(await validateContinueOnlyOnFeedback(argv, isPrUrl, isIssueUrl))) {
183
178
  // Validate model name EARLY - always runs regardless of --skip-tool-connection-check
184
179
  const tool = argv.tool || 'claude';
185
180
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
181
+ if (argv.fallbackModel) await validateAndExitOnInvalidModel(argv.fallbackModel, tool, safeExit);
182
+ argv.originalModel ||= argv.model;
186
183
 
187
184
  // Validate --plan-model if provided (Issue #1223)
188
185
  if (argv.planModel) {
@@ -912,7 +909,7 @@ try {
912
909
  await log(` ${claudeResumeCmd}`);
913
910
  await log('');
914
911
  } else if (argv.url) {
915
- const solveResumeCmd = buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: toolForResume, model: argv.model, tempDir });
912
+ const solveResumeCmd = buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: toolForResume, model: argv.model, fallbackModel: argv.fallbackModel, tempDir });
916
913
  await log(`šŸ’” To continue this ${toolForResume} session with solve:`);
917
914
  await log('');
918
915
  await log(` ${solveResumeCmd}`);
@@ -926,7 +923,7 @@ try {
926
923
  try {
927
924
  // Build Claude CLI resume command
928
925
  const tool = argv.tool || 'claude';
929
- const resumeCommand = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, tempDir }) : null;
926
+ const resumeCommand = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null;
930
927
  const logUploadSuccess = await attachLogToGitHub({
931
928
  logFile: getLogFile(),
932
929
  targetType: 'pr',
@@ -942,7 +939,7 @@ try {
942
939
  toolName: getToolDisplayName(argv.tool),
943
940
  resumeCommand,
944
941
  sessionId,
945
- requestedModel: argv.model,
942
+ requestedModel: argv.originalModel || argv.model,
946
943
  tool: argv.tool || 'claude',
947
944
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
948
945
  resultModelUsage,
@@ -964,7 +961,7 @@ try {
964
961
  const resetTime = global.limitResetTime;
965
962
  // Build Claude CLI resume command
966
963
  const tool = argv.tool || 'claude';
967
- const resumeCmd = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, tempDir }) : null;
964
+ const resumeCmd = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null;
968
965
  const resumeSection = resumeCmd ? `To resume after the limit resets, use:\n\`\`\`bash\n${resumeCmd}\n\`\`\`` : `Session ID: \`${sessionId}\``;
969
966
  // Format the reset time with relative time and UTC conversion if available
970
967
  const timezone = global.limitTimezone || null;
@@ -992,7 +989,7 @@ try {
992
989
  try {
993
990
  // Build Claude CLI resume command (only for logging, not shown to users when auto-resume is enabled)
994
991
  const tool = argv.tool || 'claude';
995
- const resumeCommand = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, tempDir }) : null;
992
+ const resumeCommand = tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : sessionId ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null;
996
993
  const logUploadSuccess = await attachLogToGitHub({
997
994
  logFile: getLogFile(),
998
995
  targetType: 'pr',
@@ -1012,7 +1009,7 @@ try {
1012
1009
  // See: https://github.com/link-assistant/hive-mind/issues/1152
1013
1010
  isAutoResumeEnabled: true,
1014
1011
  autoResumeMode: limitContinueMode,
1015
- requestedModel: argv.model,
1012
+ requestedModel: argv.originalModel || argv.model,
1016
1013
  tool: argv.tool || 'claude',
1017
1014
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
1018
1015
  resultModelUsage,
@@ -1081,7 +1078,7 @@ try {
1081
1078
  await log(` ${claudeResumeCmd}`);
1082
1079
  await log('');
1083
1080
  } else if (sessionId && argv.url) {
1084
- const solveResumeCmd = buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: toolForFailure, model: argv.model, tempDir });
1081
+ const solveResumeCmd = buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: toolForFailure, model: argv.model, fallbackModel: argv.fallbackModel, tempDir });
1085
1082
  await log('');
1086
1083
  await log(`šŸ’” To continue this ${toolForFailure} session with solve:`);
1087
1084
  await log('');
@@ -1101,7 +1098,7 @@ try {
1101
1098
  try {
1102
1099
  // Build Claude CLI resume command
1103
1100
  const tool = argv.tool || 'claude';
1104
- const resumeCommand = sessionId ? (tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, tempDir })) : null;
1101
+ const resumeCommand = sessionId ? (tool === 'claude' ? buildClaudeResumeCommand({ tempDir, sessionId, model: argv.model }) : buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool, model: argv.model, fallbackModel: argv.fallbackModel, tempDir })) : null;
1105
1102
  const logUploadSuccess = await attachLogToGitHub({
1106
1103
  logFile: getLogFile(),
1107
1104
  targetType: logTargetType,
@@ -1120,7 +1117,7 @@ try {
1120
1117
  sessionId,
1121
1118
  // If not a usage limit case, fall back to generic failure format
1122
1119
  errorMessage: limitReached ? undefined : `${argv.tool.toUpperCase()} execution failed`,
1123
- requestedModel: argv.model,
1120
+ requestedModel: argv.originalModel || argv.model,
1124
1121
  tool: argv.tool || 'claude',
1125
1122
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
1126
1123
  resultModelUsage,
@@ -1383,7 +1380,7 @@ try {
1383
1380
  sessionId,
1384
1381
  tempDir,
1385
1382
  anthropicTotalCostUSD,
1386
- requestedModel: argv.model,
1383
+ requestedModel: argv.originalModel || argv.model,
1387
1384
  tool: argv.tool || 'claude',
1388
1385
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
1389
1386
  resultModelUsage,
@@ -88,7 +88,7 @@ export async function notifyIssueAboutPrePullRequestFailure(options) {
88
88
  sanitizeLogContent,
89
89
  verbose: argv.verbose,
90
90
  errorMessage: `The solver stopped before creating a pull request.\n\nReason: ${reason || 'Unknown error'}`,
91
- requestedModel: argv.model,
91
+ requestedModel: argv.originalModel || argv.model,
92
92
  tool: argv.tool || 'claude',
93
93
  });
94
94
  if (uploaded) {
@@ -47,12 +47,13 @@ export const { buildClaudeResumeCommand, buildClaudeInitialCommand } = claudeCom
47
47
  * @param {string} options.sessionId - The session ID to resume
48
48
  * @param {string|null} [options.tool] - Tool name (codex, opencode, agent)
49
49
  * @param {string|null} [options.model] - Model name to preserve
50
+ * @param {string|null} [options.fallbackModel] - Explicit fallback model to preserve
50
51
  * @param {string|null} [options.tempDir] - Working directory to preserve
51
52
  * @param {string} [options.nodePath] - Node binary path
52
53
  * @param {string} [options.scriptPath] - solve.mjs path
53
54
  * @returns {string}
54
55
  */
55
- export const buildSolveResumeCommand = ({ issueUrl, sessionId, tool = null, model = null, tempDir = null, nodePath = process.argv[0], scriptPath = process.argv[1] }) => {
56
+ export const buildSolveResumeCommand = ({ issueUrl, sessionId, tool = null, model = null, fallbackModel = null, tempDir = null, nodePath = process.argv[0], scriptPath = process.argv[1] }) => {
56
57
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
57
58
 
58
59
  const args = [shellQuote(scriptPath), shellQuote(issueUrl), '--resume', shellQuote(sessionId)];
@@ -65,6 +66,10 @@ export const buildSolveResumeCommand = ({ issueUrl, sessionId, tool = null, mode
65
66
  args.push('--model', shellQuote(model));
66
67
  }
67
68
 
69
+ if (fallbackModel) {
70
+ args.push('--fallback-model', shellQuote(fallbackModel));
71
+ }
72
+
68
73
  if (tempDir) {
69
74
  args.push('--working-directory', shellQuote(tempDir));
70
75
  }
@@ -566,7 +571,7 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
566
571
  await log(` ${claudeResumeCmd}`);
567
572
  await log('');
568
573
  } else if (issueUrl) {
569
- const solveResumeCmd = buildSolveResumeCommand({ issueUrl, sessionId, tool, model: argv.model, tempDir });
574
+ const solveResumeCmd = buildSolveResumeCommand({ issueUrl, sessionId, tool, model: argv.model, fallbackModel: argv.fallbackModel, tempDir });
570
575
  await log('');
571
576
  await log(`šŸ’” To continue this ${tool} session with solve:`);
572
577
  await log('');
@@ -577,11 +582,12 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
577
582
  if (limitReached) {
578
583
  await log('ā° LIMIT REACHED DETECTED!');
579
584
 
580
- if (argv.autoResumeOnLimitReset && global.limitResetTime) {
581
- await log(`\nšŸ”„ AUTO-RESUME ON LIMIT RESET ENABLED - Will resume at ${global.limitResetTime}`);
585
+ if ((argv.autoResumeOnLimitReset || argv.autoRestartOnLimitReset) && global.limitResetTime) {
586
+ const isRestart = !!argv.autoRestartOnLimitReset;
587
+ await log(`\nšŸ”„ AUTO-${isRestart ? 'RESTART' : 'RESUME'} ON LIMIT RESET ENABLED - Will ${isRestart ? 'restart' : 'resume'} at ${global.limitResetTime}`);
582
588
  // Pass tempDir to ensure resumed session uses the same working directory
583
589
  // This is critical for Claude Code session resume to work correctly
584
- await autoContinueWhenLimitResets(issueUrl, sessionId, argv, shouldAttachLogs, tempDir);
590
+ await autoContinueWhenLimitResets(issueUrl, sessionId, argv, shouldAttachLogs, tempDir, isRestart);
585
591
  } else {
586
592
  if (global.limitResetTime) {
587
593
  await log(`\nā° Limit resets at: ${global.limitResetTime}`);
@@ -823,7 +829,7 @@ Fixes ${issueRef}
823
829
  // Issue #1152: Pass sessionType for differentiated log comments
824
830
  sessionType,
825
831
  // Issue #1225: Pass model and tool info for PR comments
826
- requestedModel: argv.model,
832
+ requestedModel: argv.originalModel || argv.model,
827
833
  tool: argv.tool || 'claude',
828
834
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
829
835
  resultModelUsage,
@@ -909,7 +915,7 @@ Fixes ${issueRef}
909
915
  // Issue #1152: Pass sessionType for differentiated log comments
910
916
  sessionType,
911
917
  // Issue #1225: Pass model and tool info for issue comments
912
- requestedModel: argv.model,
918
+ requestedModel: argv.originalModel || argv.model,
913
919
  tool: argv.tool || 'claude',
914
920
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
915
921
  resultModelUsage,
@@ -1000,7 +1006,7 @@ export const handleExecutionError = async (error, shouldAttachLogs, owner, repo,
1000
1006
  verbose: argv.verbose || false,
1001
1007
  errorMessage: cleanErrorMessage(error),
1002
1008
  // Issue #1225: Pass model and tool info for PR comments
1003
- requestedModel: argv.model,
1009
+ requestedModel: argv.originalModel || argv.model,
1004
1010
  tool: argv.tool || 'claude',
1005
1011
  });
1006
1012
 
@@ -39,6 +39,7 @@ const { checkPRMerged, checkForUncommittedChanges, getUncommittedChangesDetails,
39
39
 
40
40
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
41
41
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
42
+ const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit } = await import('./auto-iteration-limits.lib.mjs');
42
43
 
43
44
  // Issue #1625: Central marker constants + tracked comment posting
44
45
  const toolComments = await import('./tool-comments.lib.mjs');
@@ -52,7 +53,7 @@ export const watchForFeedback = async params => {
52
53
 
53
54
  const watchInterval = argv.watchInterval || 60; // seconds
54
55
  const isTemporaryWatch = argv.temporaryWatch || false;
55
- const maxAutoRestartIterations = argv.autoRestartMaxIterations || 3;
56
+ const maxAutoRestartIterations = normalizeAutoIterationLimit(argv.autoRestartMaxIterations);
56
57
 
57
58
  // Track latest session data across all iterations for accurate pricing
58
59
  let latestSessionId = null;
@@ -75,7 +76,7 @@ export const watchForFeedback = async params => {
75
76
  await log(formatAligned('', 'Monitoring PR:', `#${prNumber}`, 2));
76
77
  await log(formatAligned('', 'Mode:', 'Auto-restart (NOT --watch mode)', 2));
77
78
  await log(formatAligned('', 'Stop conditions:', 'All changes committed OR PR merged OR max iterations reached', 2));
78
- await log(formatAligned('', 'Max iterations:', `${maxAutoRestartIterations}`, 2));
79
+ await log(formatAligned('', 'Max iterations:', formatAutoIterationLimit(maxAutoRestartIterations), 2));
79
80
  await log(formatAligned('', 'Note:', 'No wait time between iterations in auto-restart mode', 2));
80
81
  } else {
81
82
  await log(formatAligned('šŸ‘ļø', 'WATCH MODE ACTIVATED', ''));
@@ -117,7 +118,7 @@ export const watchForFeedback = async params => {
117
118
  }
118
119
 
119
120
  // Check if we've reached max iterations
120
- if (autoRestartCount >= maxAutoRestartIterations) {
121
+ if (hasReachedAutoIterationLimit(autoRestartCount, maxAutoRestartIterations)) {
121
122
  await log('');
122
123
  await log(formatAligned('āš ļø', 'MAX ITERATIONS REACHED', `Exiting auto-restart mode after ${autoRestartCount} iterations`));
123
124
  await log(formatAligned('', 'Some uncommitted changes may remain', '', 2));
@@ -188,7 +189,7 @@ export const watchForFeedback = async params => {
188
189
  // Post a comment to PR about auto-restart
189
190
  if (prNumber) {
190
191
  try {
191
- const remainingIterations = maxAutoRestartIterations - autoRestartCount;
192
+ const remainingIterations = maxAutoRestartIterations === 0 ? null : maxAutoRestartIterations - autoRestartCount;
192
193
 
193
194
  // Get uncommitted files list for the comment
194
195
  let uncommittedFilesList = '';
@@ -196,7 +197,9 @@ export const watchForFeedback = async params => {
196
197
  uncommittedFilesList = '\n\n**Uncommitted files:**\n```\n' + changes.join('\n') + '\n```';
197
198
  }
198
199
 
199
- const commentBody = `## šŸ”„ ${AUTO_RESTART_MARKER} ${autoRestartCount}/${maxAutoRestartIterations}\n\nDetected uncommitted changes from previous run. Starting new session to review and commit or discard them.${uncommittedFilesList}\n\n---\n*Auto-restart will stop after changes are committed or discarded, or after ${remainingIterations} more iteration${remainingIterations !== 1 ? 's' : ''}. Please wait until working session will end and give your feedback.*`;
200
+ const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
201
+ const stopText = remainingIterations === null ? 'Auto-restart is configured with no iteration limit.' : `Auto-restart will stop after changes are committed or discarded, or after ${remainingIterations} more iteration${remainingIterations !== 1 ? 's' : ''}.`;
202
+ const commentBody = `## šŸ”„ ${AUTO_RESTART_MARKER} ${iterationLabel}\n\nDetected uncommitted changes from previous run. Starting new session to review and commit or discard them.${uncommittedFilesList}\n\n---\n*${stopText} Please wait until working session will end and give your feedback.*`;
200
203
  // Issue #1625: Track so this doesn't falsely count as AI-authored.
201
204
  await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
202
205
  await log(formatAligned('', 'šŸ’¬ Posted auto-restart notification to PR', '', 2));
@@ -283,7 +286,8 @@ export const watchForFeedback = async params => {
283
286
  const logFile = getLogFile();
284
287
  if (logFile) {
285
288
  // Use "Auto-restart X/Y Failure Log" format to distinguish from success logs
286
- const customTitle = `āš ļø Auto-restart ${autoRestartCount}/${maxAutoRestartIterations} Failure Log`;
289
+ const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
290
+ const customTitle = `āš ļø Auto-restart ${iterationLabel} Failure Log`;
287
291
  const logUploadSuccess = await attachLogToGitHub({
288
292
  logFile,
289
293
  targetType: 'pr',
@@ -306,7 +310,7 @@ export const watchForFeedback = async params => {
306
310
  isUsageLimit: toolResult.limitReached,
307
311
  limitResetTime: toolResult.limitResetTime,
308
312
  // Issue #1225: Pass model and tool info for PR comments
309
- requestedModel: argv.model,
313
+ requestedModel: argv.originalModel || argv.model,
310
314
  tool: argv.tool || 'claude',
311
315
  // Issue #1508: Pass model usage for failure log (cost info per model)
312
316
  resultModelUsage: toolResult.resultModelUsage || null,
@@ -372,7 +376,8 @@ export const watchForFeedback = async params => {
372
376
  const logFile = getLogFile();
373
377
  if (logFile) {
374
378
  // Use "Auto-restart X/Y Log" format as requested in issue #1107
375
- const customTitle = `šŸ”„ Auto-restart ${autoRestartCount}/${maxAutoRestartIterations} Log`;
379
+ const iterationLabel = maxAutoRestartIterations === 0 ? `${autoRestartCount}` : `${autoRestartCount}/${maxAutoRestartIterations}`;
380
+ const customTitle = `šŸ”„ Auto-restart ${iterationLabel} Log`;
376
381
  const logUploadSuccess = await attachLogToGitHub({
377
382
  logFile,
378
383
  targetType: 'pr',
@@ -391,7 +396,7 @@ export const watchForFeedback = async params => {
391
396
  publicPricingEstimate: toolResult.publicPricingEstimate,
392
397
  pricingInfo: toolResult.pricingInfo,
393
398
  // Issue #1225: Pass model and tool info for PR comments
394
- requestedModel: argv.model,
399
+ requestedModel: argv.originalModel || argv.model,
395
400
  tool: argv.tool || 'claude',
396
401
  // Issue #1508: Include budget stats (context/token/cost) for auto-restart log
397
402
  resultModelUsage: toolResult.resultModelUsage || null,
@@ -49,7 +49,7 @@ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFro
49
49
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
50
50
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
51
51
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
52
- const { trackSession, startSessionMonitoring, hasActiveSessionForUrl } = await import('./session-monitor.lib.mjs');
52
+ const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
53
53
 
54
54
  const config = yargs(hideBin(process.argv))
55
55
  .usage('Usage: hive-telegram-bot [options]')
@@ -549,7 +549,7 @@ async function safeReply(ctx, text, options = {}) {
549
549
  }
550
550
  }
551
551
 
552
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null) {
552
+ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
553
553
  const { chat, message_id: msgId } = startingMessage;
554
554
  const safeEdit = async text => {
555
555
  try {
@@ -567,19 +567,19 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
567
567
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
568
568
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
569
569
  extraInfo = `\nšŸ”’ Isolation: \`${iso.backend}\``;
570
- if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session }, VERBOSE);
570
+ if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool }, VERBOSE);
571
571
  } else {
572
572
  result = await executeStartScreen(commandName, args);
573
573
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
574
574
  session = match ? match[1] : 'unknown';
575
575
  // Issue #1586: Track non-isolation sessions with timeout-based expiry.
576
576
  // These sessions cannot reliably detect completion (screen stays alive via
577
- // `exec bash`), so hasActiveSessionForUrl() auto-expires them after 10 min.
577
+ // `exec bash`), so active URL checks auto-expire them after 10 min.
578
578
  // This prevents accidental duplicate commands within the timeout window.
579
- if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName }, VERBOSE);
579
+ if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool }, VERBOSE);
580
580
  }
581
581
  if (result.warning) return safeEdit(`āš ļø ${result.warning}`);
582
- if (result.success) await safeEdit(`āœ… ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command started successfully!\n\nšŸ“Š Session: \`${session}\`${extraInfo}\n\n${infoBlock}\n\nšŸ”” You will receive a notification when the session finishes.`);
582
+ if (result.success) await safeEdit(`šŸ”„ ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command executing...\n\nStatus: \`Executing...\`\nšŸ“Š Session: \`${session}\`${extraInfo}\n\n${infoBlock}\n\nšŸ”” This message will update when the session finishes.`);
583
583
  else await safeEdit(`āŒ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
584
584
  }
585
585
 
@@ -990,7 +990,7 @@ async function handleSolveCommand(ctx) {
990
990
  return;
991
991
  }
992
992
  // Issue #1567: Prevent concurrent sessions on the same PR/issue
993
- const activeSession = hasActiveSessionForUrl(normalizedUrl, VERBOSE);
993
+ const activeSession = await hasActiveSessionForUrlAsync(normalizedUrl, VERBOSE);
994
994
  if (activeSession.isActive) {
995
995
  await safeReply(ctx, `āŒ A working session is already running for this URL.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nSession: \`${activeSession.sessionName}\`\n\nšŸ’” Wait for the current session to complete, or use /solve\\_stop to cancel it.`, { reply_to_message_id: ctx.message.message_id });
996
996
  return;
@@ -1006,7 +1006,7 @@ async function handleSolveCommand(ctx) {
1006
1006
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
1007
1007
  if (check.canStart && toolQueuedCount === 0) {
1008
1008
  const startingMessage = await safeReply(ctx, `šŸš€ Starting solve command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1009
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation);
1009
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
1010
1010
  } else {
1011
1011
  const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
1012
1012
  let queueMessage = `šŸ“‹ Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
@@ -1171,7 +1171,7 @@ async function handleHiveCommand(ctx) {
1171
1171
  }
1172
1172
 
1173
1173
  const startingMessage = await safeReply(ctx, `šŸš€ Starting hive command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1174
- await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation);
1174
+ await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation, hiveTool);
1175
1175
  }
1176
1176
 
1177
1177
  bot.command(/^hive$/i, handleHiveCommand);
@@ -80,8 +80,8 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
80
80
  if (iso) {
81
81
  const sid = iso.runner.generateSessionId();
82
82
  const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
83
- if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid }, verbose);
84
- return { ...r, output: r.output || `session: ${sid}` };
83
+ if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid, tool: item.tool || 'claude' }, verbose);
84
+ return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
85
85
  }
86
86
  return fallbackCallback(item);
87
87
  };
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
19
19
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
- import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
+ import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
21
21
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
22
22
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
23
23
 
@@ -133,6 +133,8 @@ export class SolveQueue {
133
133
  this.verbose = options.verbose || false;
134
134
  this.executeCallback = options.executeCallback || null;
135
135
  this.messageUpdateCallback = options.messageUpdateCallback || null;
136
+ this.getRunningProcessesFn = options.getRunningProcesses || getRunningProcesses;
137
+ this.getRunningIsolatedSessionsFn = options.getRunningIsolatedSessions || getRunningIsolatedSessions;
136
138
 
137
139
  // Separate queues per tool type - claude tasks never block agent tasks
138
140
  // See: https://github.com/link-assistant/hive-mind/issues/1159
@@ -462,6 +464,46 @@ export class SolveQueue {
462
464
  };
463
465
  }
464
466
 
467
+ /**
468
+ * Get external processing counts from both process scanning and tracked
469
+ * isolated sessions. The displayed/accounted value is the maximum of the two
470
+ * sources so screen-isolated sessions remain visible even when the AI CLI
471
+ * process is not directly observable, while regular non-isolated runs still
472
+ * use pgrep as before.
473
+ *
474
+ * @param {string[]} tools - Tool queues to count
475
+ * @returns {Promise<{byTool: Object, processByTool: Object, isolatedByTool: Object, total: number, isolatedTotal: number, processTotal: number}>}
476
+ */
477
+ async getExternalProcessingSnapshot(tools = Object.keys(this.queues)) {
478
+ const uniqueTools = [...new Set(tools)];
479
+ const isolated = await this.getRunningIsolatedSessionsFn(this.verbose);
480
+ const isolatedByTool = isolated.byTool || {};
481
+ const processByTool = {};
482
+ const byTool = {};
483
+
484
+ await Promise.all(
485
+ uniqueTools.map(async tool => {
486
+ const result = await this.getRunningProcessesFn(tool, this.verbose);
487
+ const processCount = result?.count || 0;
488
+ const isolatedCount = isolatedByTool[tool] || 0;
489
+ processByTool[tool] = processCount;
490
+ byTool[tool] = Math.max(processCount, isolatedCount);
491
+ })
492
+ );
493
+
494
+ const processTotal = Object.values(processByTool).reduce((sum, count) => sum + count, 0);
495
+ const isolatedTotal = isolated.count || Object.values(isolatedByTool).reduce((sum, count) => sum + count, 0);
496
+
497
+ return {
498
+ byTool,
499
+ processByTool,
500
+ isolatedByTool,
501
+ total: Math.max(processTotal, isolatedTotal),
502
+ isolatedTotal,
503
+ processTotal,
504
+ };
505
+ }
506
+
465
507
  /**
466
508
  * Check if a new command can start
467
509
  *
@@ -505,16 +547,19 @@ export class SolveQueue {
505
547
  }
506
548
  }
507
549
 
508
- // Check running claude processes (this is a metric, not a blocking reason by itself)
509
- const claudeProcs = await getRunningClaudeProcesses(this.verbose);
510
- const codexProcs = await getRunningCodexProcesses(this.verbose);
511
- const agentProcs = await getRunningAgentProcesses(this.verbose);
512
- const hasRunningClaude = claudeProcs.count > 0;
513
- const hasRunningCodex = codexProcs.count > 0;
550
+ // Check running tool processes (this is a metric, not a blocking reason by itself).
551
+ // For screen-isolated sessions, use the maximum of `$ --status` executing
552
+ // counts and pgrep counts so detached sessions remain visible.
553
+ const externalProcessing = await this.getExternalProcessingSnapshot([...Object.keys(this.queues), tool]);
554
+ const claudeProcessCount = externalProcessing.byTool.claude || 0;
555
+ const codexProcessCount = externalProcessing.byTool.codex || 0;
556
+ const agentProcessCount = externalProcessing.byTool.agent || 0;
557
+ const hasRunningClaude = claudeProcessCount > 0;
558
+ const hasRunningCodex = codexProcessCount > 0;
514
559
 
515
560
  // Calculate total processing count for system resources (all tools)
516
561
  // System resources (RAM, CPU, disk) apply to all tools
517
- const totalProcessing = this.processing.size + claudeProcs.count + codexProcs.count + agentProcs.count;
562
+ const totalProcessing = this.processing.size + externalProcessing.total;
518
563
 
519
564
  // Calculate Claude-specific processing count for Claude API limits
520
565
  // Only counts Claude items in queue + external claude processes
@@ -572,10 +617,10 @@ export class SolveQueue {
572
617
  // Add claude_running info at the END (not beginning) of reasons
573
618
  // Since it's supplementary info, not the primary blocking reason
574
619
  // See: https://github.com/link-assistant/hive-mind/issues/1078
575
- reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
620
+ reasons.push(formatWaitingReason('claude_running', claudeProcessCount, 0) + ` (${claudeProcessCount} processes)`);
576
621
  }
577
622
  if (tool === 'codex' && hasRunningCodex && reasons.length > 0) {
578
- reasons.push(formatWaitingReason('codex_running', codexProcs.count, 0) + ` (${codexProcs.count} processes)`);
623
+ reasons.push(formatWaitingReason('codex_running', codexProcessCount, 0) + ` (${codexProcessCount} processes)`);
579
624
  }
580
625
 
581
626
  const canStart = reasons.length === 0 && !rejected;
@@ -595,8 +640,10 @@ export class SolveQueue {
595
640
  reason: reasons.length > 0 ? reasons.join('\n') : undefined,
596
641
  reasons,
597
642
  oneAtATime,
598
- claudeProcesses: claudeProcs.count,
599
- codexProcesses: codexProcs.count,
643
+ claudeProcesses: claudeProcessCount,
644
+ codexProcesses: codexProcessCount,
645
+ agentProcesses: agentProcessCount,
646
+ isolatedProcesses: externalProcessing.isolatedTotal,
600
647
  totalProcessing,
601
648
  claudeProcessingCount,
602
649
  codexProcessingCount,
@@ -1075,7 +1122,7 @@ export class SolveQueue {
1075
1122
  const result = await this.executeCallback(item);
1076
1123
 
1077
1124
  // Extract session name from result
1078
- let sessionName = 'unknown';
1125
+ let sessionName = result?.sessionId || 'unknown';
1079
1126
  if (result && result.output) {
1080
1127
  const sessionMatch = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/);
1081
1128
  if (sessionMatch) sessionName = sessionMatch[1];
@@ -1098,7 +1145,8 @@ export class SolveQueue {
1098
1145
  if (result.warning) {
1099
1146
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `āš ļø ${result.warning}`, { parse_mode: 'Markdown' });
1100
1147
  } else if (result.success) {
1101
- const response = `āœ… Solve command started successfully!\n\nšŸ“Š Session: \`${sessionName}\`\n\n${item.infoBlock}`;
1148
+ const isolationInfo = result.isolationBackend ? `\nšŸ”’ Isolation: \`${result.isolationBackend}\`` : '';
1149
+ const response = `šŸ”„ Solve command executing...\n\nStatus: \`Executing...\`\nšŸ“Š Session: \`${sessionName}\`${isolationInfo}\n\n${item.infoBlock}\n\nšŸ”” This message will update when the session finishes.`;
1102
1150
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
1103
1151
  } else {
1104
1152
  const response = `āŒ Error executing solve command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``;
@@ -1177,9 +1225,8 @@ export class SolveQueue {
1177
1225
  * Format queue status for display in /limits command
1178
1226
  * Shows per-tool queue breakdown with processing counts.
1179
1227
  *
1180
- * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1181
- * This is because items transition quickly through the processing state, but the actual
1182
- * work happens in the spawned system process (claude, agent, etc.).
1228
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1229
+ * `$ --status` executing screen-isolated sessions), not queue state.
1183
1230
  *
1184
1231
  * Output format:
1185
1232
  * ```
@@ -1194,10 +1241,11 @@ export class SolveQueue {
1194
1241
  */
1195
1242
  async formatStatus() {
1196
1243
  // Always show per-tool breakdown for all known queues
1244
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1197
1245
  let message = 'Queues\n';
1198
1246
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1199
1247
  const pending = toolQueue.length;
1200
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1248
+ const processing = externalProcessing.byTool[tool] || 0;
1201
1249
  message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
1202
1250
  }
1203
1251
 
@@ -1208,9 +1256,8 @@ export class SolveQueue {
1208
1256
  * Format detailed queue status for Telegram message
1209
1257
  * Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
1210
1258
  *
1211
- * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1212
- * This is because items transition quickly through the processing state, but the actual
1213
- * work happens in the spawned system process (claude, agent, etc.).
1259
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1260
+ * `$ --status` executing screen-isolated sessions), not queue state.
1214
1261
  *
1215
1262
  * Output format:
1216
1263
  * ```
@@ -1231,16 +1278,17 @@ export class SolveQueue {
1231
1278
  */
1232
1279
  async formatDetailedStatus() {
1233
1280
  const stats = this.getStats();
1281
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1234
1282
 
1235
- // Get actual process counts for each tool queue
1236
- // The "processing" count is the number of running system processes, not queue internal state
1237
- // This ensures users see accurate counts of what's actually running
1283
+ // Get actual processing counts for each tool queue.
1284
+ // This combines pgrep with tracked isolation status so users see detached
1285
+ // screen-isolated work even when the direct AI CLI process count is lower.
1238
1286
  let message = 'šŸ“‹ *Solve Queue Status*\n\n';
1239
1287
 
1240
1288
  // Show per-tool queue breakdown with items grouped by queue
1241
1289
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1242
1290
  const pending = toolQueue.length;
1243
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1291
+ const processing = externalProcessing.byTool[tool] || 0;
1244
1292
  message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
1245
1293
 
1246
1294
  // Show first 5 queued items for this tool
@@ -1308,7 +1356,7 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1308
1356
  const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
1309
1357
  const session = match ? match[1] : null;
1310
1358
  if (session) {
1311
- trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve' });
1359
+ trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', tool: item.tool || 'claude' });
1312
1360
  }
1313
1361
  }
1314
1362
  return result;
@@ -1316,23 +1364,21 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1316
1364
  }
1317
1365
 
1318
1366
  /**
1319
- * Get count of running isolated sessions tracked via ExecutionStore
1320
- * When isolation mode is enabled, this replaces pgrep-based process detection
1321
- * for more reliable task counting.
1367
+ * Get count of tracked isolated sessions that are still executing according
1368
+ * to `$ --status`. Queue display combines this with pgrep counts using max().
1322
1369
  *
1323
1370
  * @param {boolean} verbose - Whether to log verbose output
1324
- * @returns {Promise<{count: number, sessions: string[]}>}
1371
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
1325
1372
  */
1326
1373
  export async function getRunningIsolatedSessions(verbose = false) {
1327
1374
  try {
1328
- const { getActiveSessionCount } = await import('./session-monitor.lib.mjs');
1329
- const count = getActiveSessionCount(verbose);
1330
- return { count, sessions: [] };
1375
+ const { getRunningTrackedIsolationSessions } = await import('./session-monitor.lib.mjs');
1376
+ return await getRunningTrackedIsolationSessions(verbose);
1331
1377
  } catch (error) {
1332
1378
  if (verbose) {
1333
1379
  console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
1334
1380
  }
1335
- return { count: 0, sessions: [] };
1381
+ return { count: 0, sessions: [], byTool: {} };
1336
1382
  }
1337
1383
  }
1338
1384