@link-assistant/hive-mind 2.13.1 → 2.13.3

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.
@@ -22,7 +22,7 @@ import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs
22
22
  import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
23
23
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
24
24
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
25
- import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
25
+ import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
26
26
  import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
27
27
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
28
28
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
@@ -391,12 +391,15 @@ export const executeGeminiCommand = async params => {
391
391
  const { tempDir, workspaceTmpDir, branchName, prompt, systemPrompt, argv, log, formatAligned, getResourceSnapshot, forkedRepo, feedbackLines, geminiPath, $, waitForRetryDelay = waitWithCountdown } = params;
392
392
 
393
393
  let retryCount = 0;
394
+ // Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
395
+ // HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
396
+ const transientRetryBudget = createTransientRetryBudget();
394
397
 
395
398
  const executeWithRetry = async () => {
396
399
  if (retryCount === 0) {
397
400
  await log(`\n${formatAligned('šŸ¤–', 'Executing Gemini:', argv.model.toUpperCase())}`);
398
401
  } else {
399
- await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
402
+ await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
400
403
  }
401
404
 
402
405
  if (argv.verbose) {
@@ -515,7 +518,17 @@ export const executeGeminiCommand = async params => {
515
518
  if (retryableError.isRetryable) {
516
519
  const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
517
520
  const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
518
- if (retryCount < maxRetries) {
521
+ // Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
522
+ // (configurable) decides when to stop, and every wait honours the 3-minute minimum.
523
+ const retryDecision = transientRetryBudget.evaluate({
524
+ retryCount,
525
+ maxRetries,
526
+ initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
527
+ maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
528
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
529
+ });
530
+ if (retryDecision.allowed) {
531
+ transientRetryBudget.grant();
519
532
  // Issue #2037: retry the same model on capacity errors before falling back;
520
533
  // after a capacity-driven model switch, retry quickly instead of waiting the
521
534
  // full transient backoff — the new model may be available now.
@@ -527,16 +540,17 @@ export const executeGeminiCommand = async params => {
527
540
  retryCount,
528
541
  initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
529
542
  maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
543
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
530
544
  });
531
545
  const delay = retryPlan.delay;
532
546
  const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
533
- await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
547
+ await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
534
548
  await waitForRetryDelay(delay, log);
535
549
  await log('\nšŸ”„ Retrying now...');
536
550
  retryCount++;
537
551
  return await executeWithRetry();
538
552
  }
539
- await log(`\n\nāŒ ${retryableError.label} persisted after ${maxRetries} retries`, { level: 'error' });
553
+ await log(`\n\nāŒ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
540
554
  }
541
555
 
542
556
  const limitInfo = detectUsageLimit(errorText);
@@ -5,7 +5,8 @@ import { reportError } from './sentry.lib.mjs';
5
5
  * @param {string} url - The GitHub URL to parse
6
6
  * @returns {Object} Parsed URL information including:
7
7
  * - valid: boolean indicating if the URL is valid
8
- * - normalized: the normalized URL (https://github.com/...)
8
+ * - normalized: the normalized URL (https://github.com/...), query/fragment kept
9
+ * - canonical: the URL the bot actually interprets (no query string, no #fragment)
9
10
  * - type: 'user', 'repo', 'issue', 'pull', 'gist', 'actions', etc.
10
11
  * - owner: repository owner/organization
11
12
  * - repo: repository name (if applicable)
@@ -96,6 +97,13 @@ export function parseGitHubUrl(url) {
96
97
  const result = {
97
98
  valid: true,
98
99
  normalized: normalizedUrl,
100
+ // Issue #2166: `normalized` keeps whatever query string or fragment the user
101
+ // pasted (`…/pull/18#issuecomment-5370631063`), but nothing downstream reads
102
+ // it — the bot resolves the target from owner/repo/number alone. Echoing the
103
+ // fragment back therefore claims an interpretation that never happened, so
104
+ // `canonical` is the URL the bot actually acted on and is what gets shown,
105
+ // queued and matched against.
106
+ canonical: `https://github.com${urlObj.pathname.replace(/\/+$/, '')}`,
99
107
  hostname: 'github.com',
100
108
  protocol: 'https',
101
109
  path: urlObj.pathname,
@@ -242,6 +250,23 @@ export function normalizeGitHubUrl(url) {
242
250
  return parsed.valid ? parsed.normalized : null;
243
251
  }
244
252
 
253
+ /**
254
+ * Reduce a GitHub URL to the part the tooling actually interprets: no query
255
+ * string, no `#issuecomment-…` fragment, no trailing slash.
256
+ *
257
+ * Used wherever a URL is echoed back to a user, stored as a queue key, or
258
+ * compared against another URL, so that a link copied from a comment and the
259
+ * same link copied from the address bar name one and the same task (issue #2166).
260
+ *
261
+ * @param {string} url
262
+ * @returns {string} The canonical URL, or the trimmed input when it cannot be parsed.
263
+ */
264
+ export function canonicalizeGitHubUrl(url) {
265
+ if (!url || typeof url !== 'string') return url;
266
+ const parsed = parseGitHubUrl(url);
267
+ return parsed.valid && parsed.canonical ? parsed.canonical : url.trim();
268
+ }
269
+
245
270
  /** Build the canonical web URL for a pull request already identified by GitHub. */
246
271
  export function buildGitHubPullRequestUrl({ owner, repo, number } = {}) {
247
272
  if (!owner || !repo || !Number.isInteger(Number(number)) || Number(number) <= 0) {
@@ -25,7 +25,7 @@ import { checkPlaywrightMcpPackageAvailability, getOpenCodePlaywrightMcpDisableE
25
25
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
26
26
  import { createJsonStreamScanner } from './json-stream.lib.mjs';
27
27
  import { calculateAgentPricing } from './agent.lib.mjs';
28
- import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
28
+ import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
29
29
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
30
30
 
31
31
  export { parseOpenCodeTokenUsage };
@@ -193,13 +193,16 @@ export const executeOpenCodeCommand = async params => {
193
193
 
194
194
  // Retry configuration
195
195
  let retryCount = 0;
196
+ // Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
197
+ // HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
198
+ const transientRetryBudget = createTransientRetryBudget();
196
199
 
197
200
  const executeWithRetry = async () => {
198
201
  // Execute opencode command from the cloned repository directory
199
202
  if (retryCount === 0) {
200
203
  await log(`\n${formatAligned('šŸ¤–', 'Executing OpenCode:', argv.model.toUpperCase())}`);
201
204
  } else {
202
- await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
205
+ await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
203
206
  }
204
207
 
205
208
  if (argv.verbose) {
@@ -483,7 +486,17 @@ export const executeOpenCodeCommand = async params => {
483
486
  if (retryableError.isRetryable) {
484
487
  const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
485
488
  const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
486
- if (retryCount < maxRetries) {
489
+ // Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
490
+ // (configurable) decides when to stop, and every wait honours the 3-minute minimum.
491
+ const retryDecision = transientRetryBudget.evaluate({
492
+ retryCount,
493
+ maxRetries,
494
+ initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
495
+ maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
496
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
497
+ });
498
+ if (retryDecision.allowed) {
499
+ transientRetryBudget.grant();
487
500
  if (sessionId && !argv.resume) argv.resume = sessionId;
488
501
  // Issue #2037: retry the same model on capacity errors before falling back;
489
502
  // after a capacity-driven model switch, retry quickly instead of waiting the
@@ -496,16 +509,17 @@ export const executeOpenCodeCommand = async params => {
496
509
  retryCount,
497
510
  initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
498
511
  maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
512
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
499
513
  });
500
514
  const delay = retryPlan.delay;
501
515
  const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
502
- await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
516
+ await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
503
517
  await waitForRetryDelay(delay, log);
504
518
  await log('\nšŸ”„ Retrying now...');
505
519
  retryCount++;
506
520
  return await executeWithRetry();
507
521
  }
508
- await log(`\n\nāŒ ${retryableError.label} persisted after ${maxRetries} retries`, { level: 'error' });
522
+ await log(`\n\nāŒ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
509
523
  }
510
524
 
511
525
  // Check for usage limit errors first (more specific)
package/src/qwen.lib.mjs CHANGED
@@ -22,7 +22,7 @@ import { qwenModels, defaultModels, isFormalAiModel } from './models/index.mjs';
22
22
  import { buildFormalAiEnvExports, isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
23
23
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
24
24
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
25
- import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
25
+ import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
26
26
  import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
27
27
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
28
28
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
@@ -489,6 +489,9 @@ export const executeQwenCommand = async params => {
489
489
  const { tempDir, branchName, prompt, systemPrompt, argv, log, formatAligned = (_icon, label, value = '') => `${label} ${value}`.trim(), getResourceSnapshot = async () => ({ memory: '\nunknown', load: 'unknown' }), forkedRepo, feedbackLines, qwenPath = 'qwen', $: dollar = $, waitForRetryDelay = waitWithCountdown } = params;
490
490
 
491
491
  let retryCount = 0;
492
+ // Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
493
+ // HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
494
+ const transientRetryBudget = createTransientRetryBudget();
492
495
  const promptFile = path.join(os.tmpdir(), `qwen_prompt_${Date.now()}_${process.pid}.txt`);
493
496
  const systemPromptFile = path.join(os.tmpdir(), `qwen_system_prompt_${Date.now()}_${process.pid}.txt`);
494
497
 
@@ -499,7 +502,7 @@ export const executeQwenCommand = async params => {
499
502
  if (retryCount === 0) {
500
503
  await log(`\n${formatAligned('šŸ¤–', 'Executing Qwen Code:', argv.model.toUpperCase())}`);
501
504
  } else {
502
- await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
505
+ await log(`\n${formatAligned('šŸ”„', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
503
506
  }
504
507
 
505
508
  if (argv.verbose) {
@@ -625,7 +628,17 @@ export const executeQwenCommand = async params => {
625
628
  if (retryableError.isRetryable) {
626
629
  const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
627
630
  const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
628
- if (retryCount < maxRetries) {
631
+ // Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
632
+ // (configurable) decides when to stop, and every wait honours the 3-minute minimum.
633
+ const retryDecision = transientRetryBudget.evaluate({
634
+ retryCount,
635
+ maxRetries,
636
+ initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
637
+ maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
638
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
639
+ });
640
+ if (retryDecision.allowed) {
641
+ transientRetryBudget.grant();
629
642
  if (sessionId && !argv.resume) argv.resume = sessionId;
630
643
  // Issue #2037: retry the same model on capacity errors before falling back;
631
644
  // after a capacity-driven model switch, retry quickly instead of waiting the
@@ -638,16 +651,17 @@ export const executeQwenCommand = async params => {
638
651
  retryCount,
639
652
  initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
640
653
  maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
654
+ minDelayMs: retryLimits.minTransientErrorDelayMs,
641
655
  });
642
656
  const delay = retryPlan.delay;
643
657
  const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
644
- await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
658
+ await log(`\nāš ļø ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
645
659
  await waitForRetryDelay(delay, log);
646
660
  await log('\nšŸ”„ Retrying now...');
647
661
  retryCount++;
648
662
  return await executeWithRetry();
649
663
  }
650
- await log(`\n\nāŒ ${retryableError.label} persisted after ${maxRetries} retries`, { level: 'error' });
664
+ await log(`\n\nāŒ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
651
665
  } else if (exitCode === 130) {
652
666
  await log('\n\nāš ļø Qwen Code command interrupted (CTRL+C)');
653
667
  } else {
@@ -23,6 +23,7 @@ import fs from 'fs/promises';
23
23
  import { promisify } from 'util';
24
24
  import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifySessionOutcome } from './work-session-formatting.lib.mjs';
25
25
  import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
26
+ import { safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
26
27
  import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
27
28
  import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
28
29
  import { resolveFailedSessionPullRequestState } from './github-pr-state.lib.mjs';
@@ -942,11 +943,11 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
942
943
  let notifyFromChatId = null;
943
944
  let notifyMessageId = null;
944
945
  if (sessionInfo.messageId) {
945
- await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
946
+ await safeEditMessageText(bot.telegram, sessionInfo.chatId, sessionInfo.messageId, undefined, message, { verbose });
946
947
  notifyFromChatId = sessionInfo.chatId;
947
948
  notifyMessageId = sessionInfo.messageId;
948
949
  } else {
949
- const sent = await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
950
+ const sent = await safeSendMessage(bot.telegram, sessionInfo.chatId, message, { verbose });
950
951
  notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
951
952
  notifyMessageId = sent?.message_id || null;
952
953
  }
@@ -19,6 +19,7 @@
19
19
  import { promisify } from 'util';
20
20
  import { exec as execCallback } from 'child_process';
21
21
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
22
+ import { safeReply, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
22
23
 
23
24
  const execRaw = promisify(execCallback);
24
25
  // Issue #1726: rate-limit safe gh wrapper.
@@ -148,9 +149,10 @@ export function registerAcceptInvitesCommand(bot, options) {
148
149
  return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
149
150
  }
150
151
 
151
- const fetchingMessage = await ctx.reply('šŸ”„ Fetching pending GitHub invitations\\.\\.\\.', {
152
+ const fetchingMessage = await safeReply(ctx, 'šŸ”„ Fetching pending GitHub invitations\\.\\.\\.', {
152
153
  reply_to_message_id: ctx.message.message_id,
153
154
  parse_mode: 'MarkdownV2',
155
+ verbose: VERBOSE,
154
156
  });
155
157
 
156
158
  // State for tracking progress
@@ -169,7 +171,7 @@ export function registerAcceptInvitesCommand(bot, options) {
169
171
  const updateMessage = async () => {
170
172
  try {
171
173
  const message = buildProgressMessage(state);
172
- await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'MarkdownV2' });
174
+ await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'MarkdownV2', verbose: VERBOSE });
173
175
  } catch (err) {
174
176
  // Ignore "message not modified" errors
175
177
  if (!err.message?.includes('message is not modified')) {
@@ -238,7 +240,7 @@ export function registerAcceptInvitesCommand(bot, options) {
238
240
  } catch (error) {
239
241
  console.error('Error in /accept_invites:', error);
240
242
  const escapedError = escapeMarkdown(error.message);
241
- await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `āŒ Error fetching invitations: ${escapedError}\n\nMake sure \`gh\` CLI is installed and authenticated\\.`, { parse_mode: 'MarkdownV2' });
243
+ await safeEditMessageText(ctx.telegram, fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `āŒ Error fetching invitations: ${escapedError}\n\nMake sure \`gh\` CLI is installed and authenticated\\.`, { parse_mode: 'MarkdownV2', verbose: VERBOSE });
242
244
  }
243
245
  });
244
246
  }
@@ -244,8 +244,8 @@ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFro
244
244
  const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
245
245
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
246
246
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwarded: _isForwarded, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
247
- const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
248
- const { installTelegramRateLimitTracker } = await import('./telegram-rate-limit.lib.mjs');
247
+ const { isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, safeSendMessage, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
248
+ const { installTelegramContextSafety } = await import('./telegram-context-safety.lib.mjs');
249
249
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
250
250
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
251
251
  const { trackSession, untrackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl, setSessionStore, setSessionLogger, resumeTrackedSessions, getActiveSessionCount } = await import('./session-monitor.lib.mjs');
@@ -271,8 +271,10 @@ const { Telegraf } = telegrafModule;
271
271
  const bot = new Telegraf(BOT_TOKEN, {
272
272
  handlerTimeout: Infinity, // Remove default 90s timeout; command handlers like /solve spawn long-running processes
273
273
  });
274
- installTelegramFormattingFallback(bot.telegram, { verbose: VERBOSE });
275
- installTelegramRateLimitTracker(bot.telegram, { verbose: VERBOSE });
274
+ // Issue #2166: Telegraf hands every update a *new* `Telegram` client, so
275
+ // instrumenting `bot.telegram` alone leaves `ctx.reply()` unprotected. Install
276
+ // on the bot client (for background sends) and on each per-update context.
277
+ installTelegramContextSafety(bot, { verbose: VERBOSE });
276
278
  // Track bot startup time (Unix seconds to match Telegram's message.date format)
277
279
  const BOT_START_TIME = Math.floor(Date.now() / 1000);
278
280
 
@@ -522,7 +524,9 @@ bot.command('version', async ctx => {
522
524
  const { registerLanguageCommand } = await import('./telegram-language-command.lib.mjs');
523
525
  registerLanguageCommand(bot, { VERBOSE, isOldMessage, isForwardedOrReply });
524
526
  const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
525
- const sharedCommandOpts = { VERBOSE, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
527
+ // Issue #2166 (R7): every command module gets the same send helpers, so no
528
+ // command can drift back to a raw, unvalidated, unlogged `ctx.reply`.
529
+ const sharedCommandOpts = { VERBOSE, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage, safeReply, safeEditMessageText };
526
530
  registerAcceptInvitesCommand(bot, sharedCommandOpts);
527
531
  const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
528
532
  registerMergeCommand(bot, sharedCommandOpts);
@@ -673,6 +677,9 @@ async function handleSolveCommand(ctx) {
673
677
  return;
674
678
  }
675
679
  userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
680
+ // Issue #2166: hand the spawned session the same canonical URL that is shown
681
+ // in the chat, so the echo and the actual work can never disagree.
682
+ if (validation.parsed?.canonical && userArgs[0] && cleanNonPrintableChars(userArgs[0]) === validation.normalizedUrl) userArgs[0] = validation.parsed.canonical;
676
683
  const { backend: solvePerCommandIsolation, filteredArgs: userArgsWithoutIsolation } = extractIsolationFromArgs(userArgs); // issue #1534
677
684
  if (solvePerCommandIsolation && !isValidPerCommandIsolation(solvePerCommandIsolation)) {
678
685
  await safeReply(ctx, t('telegram.invalid_isolation', { value: escapeMarkdown(solvePerCommandIsolation) }, { locale: solveLocale }), { reply_to_message_id: ctx.message.message_id });
@@ -739,8 +746,10 @@ async function handleSolveCommand(ctx) {
739
746
  await safeReply(ctx, `āŒ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
740
747
  return;
741
748
  }
742
- // Use normalized URL from validation to ensure consistent duplicate detection (issue #1080)
743
- const normalizedUrl = validation.parsed.normalized;
749
+ // Use the canonical URL from validation to ensure consistent duplicate
750
+ // detection (issue #1080) and to echo back only what the bot interpreted —
751
+ // a `#issuecomment-…` fragment is never used to resolve the target (#2166).
752
+ const normalizedUrl = validation.parsed.canonical || validation.parsed.normalized;
744
753
 
745
754
  const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
746
755
  // #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
@@ -777,7 +786,7 @@ async function handleSolveCommand(ctx) {
777
786
  }
778
787
 
779
788
  // Issue #1688: parsed URL context lets the completion message look up linked PRs.
780
- const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: validation.parsed.normalized || normalizedUrl } : null;
789
+ const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: normalizedUrl } : null;
781
790
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
782
791
  // Issue #378: propagate user's effective Telegram locale to the spawned solve session.
783
792
  const argsWithLocale = injectLanguageIfMissing(args, solveLocale);
@@ -998,7 +1007,7 @@ registerLeakNotifier(async ({ owner, repo, prNumber, tokenHits = [] }) => {
998
1007
  if (creator && creator.user?.id) ownerUserId = creator.user.id;
999
1008
  }
1000
1009
  if (ownerUserId) {
1001
- await bot.telegram.sendMessage(ownerUserId, text, { parse_mode: 'Markdown' }).catch(err => {
1010
+ await safeSendMessage(bot.telegram, ownerUserId, text, { verbose: VERBOSE }).catch(err => {
1002
1011
  console.warn(`[telegram-leak-notifier] DM to user ${ownerUserId} (chat ${chatId}) failed: ${err.message}`);
1003
1012
  });
1004
1013
  }
@@ -3,6 +3,7 @@ import { describeChildExit } from './child-exit.lib.mjs';
3
3
  import { promisify } from 'util';
4
4
  import { exec as execCallback } from 'child_process';
5
5
  import { formatFailedLaunchMessage as defaultFormatFailedLaunchMessage } from './work-session-formatting.lib.mjs';
6
+ import { safeEditMessageText } from './telegram-safe-reply.lib.mjs';
6
7
 
7
8
  const exec = promisify(execCallback);
8
9
 
@@ -106,7 +107,7 @@ export function buildExecuteAndUpdateMessage(deps) {
106
107
  const { chat, message_id: msgId } = startingMessage;
107
108
  const safeEdit = async text => {
108
109
  try {
109
- await ctx.telegram.editMessageText(chat.id, msgId, undefined, text, { parse_mode: 'Markdown' });
110
+ await safeEditMessageText(ctx.telegram, chat.id, msgId, undefined, text, { verbose: VERBOSE });
110
111
  } catch (e) {
111
112
  console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
112
113
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Per-update Telegram instrumentation (issue #2166).
3
+ *
4
+ * Telegraf does **not** reuse `bot.telegram` when handling an update. Every
5
+ * update gets a brand new `Telegram` client so that `webhookReply` can answer on
6
+ * the open HTTP response:
7
+ *
8
+ * ```js
9
+ * // telegraf/lib/telegraf.js — handleUpdate()
10
+ * const tg = new telegram_1.default(this.token, this.telegram.options, webhookResponse);
11
+ * const ctx = new TelegrafContext(update, tg, this.botInfo);
12
+ * ```
13
+ *
14
+ * Consequently `ctx.telegram !== bot.telegram`, and anything installed on
15
+ * `bot.telegram` (the plain-text formatting fallback, the rate-limit tracker) is
16
+ * invisible to `ctx.reply()`, `ctx.telegram.sendMessage()` and
17
+ * `ctx.telegram.editMessageText()` — which is how *every* command handler talks
18
+ * to Telegram. That is why the `/stop` confirmation in issue #2166 died with an
19
+ * unhandled `Can't find end of the entity starting at byte offset 65` instead of
20
+ * degrading to plain text.
21
+ *
22
+ * This module re-installs the instrumentation on each freshly created context,
23
+ * so a single send path — `installTelegramFormattingFallback` — covers the whole
24
+ * bot regardless of which object a handler happens to call.
25
+ *
26
+ * @module telegram-context-safety.lib
27
+ * @see https://github.com/link-assistant/hive-mind/issues/2166
28
+ */
29
+
30
+ import { installTelegramFormattingFallback } from './telegram-safe-reply.lib.mjs';
31
+ import { installTelegramRateLimitTracker } from './telegram-rate-limit.lib.mjs';
32
+
33
+ /**
34
+ * Install the formatting fallback and rate-limit tracker on one Telegram client.
35
+ * Both installers are idempotent (guarded by a symbol), so calling this on an
36
+ * already-protected client is a no-op.
37
+ *
38
+ * @param {object} telegram - A Telegraf `Telegram` client instance.
39
+ * @param {{verbose?: boolean, fallbackLocale?: string|null, tracker?: object}} [options]
40
+ * @returns {object|null} The same client, or `null` when there is nothing to protect.
41
+ */
42
+ export function protectTelegramClient(telegram, options = {}) {
43
+ if (!telegram) return null;
44
+ const { verbose = false, fallbackLocale = null, tracker } = options;
45
+ installTelegramFormattingFallback(telegram, { verbose, fallbackLocale });
46
+ installTelegramRateLimitTracker(telegram, tracker ? { verbose, tracker } : { verbose });
47
+ return telegram;
48
+ }
49
+
50
+ /**
51
+ * Register the per-update protection as the very first middleware of a bot.
52
+ *
53
+ * Must run before any command handler so that handlers which call `ctx.reply()`
54
+ * directly still get the plain-text fallback and the send audit log.
55
+ *
56
+ * @param {object} bot - A Telegraf bot instance.
57
+ * @param {{verbose?: boolean, fallbackLocale?: string|null, tracker?: object}} [options]
58
+ * @returns {object} The bot, for chaining.
59
+ */
60
+ export function installTelegramContextSafety(bot, options = {}) {
61
+ if (!bot) return bot;
62
+ protectTelegramClient(bot.telegram, options);
63
+ if (typeof bot.use === 'function') {
64
+ bot.use((ctx, next) => {
65
+ protectTelegramClient(ctx?.telegram, options);
66
+ return next();
67
+ });
68
+ }
69
+ return bot;
70
+ }
@@ -8,6 +8,10 @@
8
8
  */
9
9
 
10
10
  import { buildUserMention } from './buildUserMention.lib.mjs';
11
+ import { calculateLevenshteinDistance } from './option-suggestions.lib.mjs';
12
+ import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
13
+ import { createYargsConfig as createSolveYargsConfig, detectMalformedFlags } from './solve.config.lib.mjs';
14
+ import { parseArgsWithYargs } from './telegram-solve-command.lib.mjs';
11
15
  import { validateModelName } from './models/index.mjs';
12
16
  import { parseFixRepository } from './fix.ci-cd.lib.mjs';
13
17
  import { getModelFromArgs } from './model-args.lib.mjs';
@@ -15,6 +19,8 @@ import { escapeMarkdown } from './telegram-markdown.lib.mjs';
15
19
  import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
16
20
  import { mergeArgsWithOverrides } from './args-overrides.lib.mjs';
17
21
  import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
22
+ import { safeReply as defaultSafeReply } from './telegram-safe-reply.lib.mjs';
23
+ import { partitionFixArgs } from './fix.ci-cd.lib.mjs';
18
24
  import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
19
25
 
20
26
  export const FIX_COMMAND_NAMES = Object.freeze(['fix']);
@@ -68,6 +74,54 @@ export function buildFixCommandArgs(text) {
68
74
  };
69
75
  }
70
76
 
77
+ /**
78
+ * Options `/fix` consumes itself; everything else is forwarded to `/solve` and
79
+ * must therefore be a valid `solve` option.
80
+ */
81
+ export const FIX_OWN_OPTIONS = Object.freeze(['--ci-cd', '--isolation', '--dry-run', '--no-solve', '--no-auto-solve', '--solve', '--help', '-h', '--version']);
82
+
83
+ /**
84
+ * Reject a `/fix` request that contains any option `fix` or `solve` cannot act on.
85
+ *
86
+ * Issue #2166: a typo such as `--ci-de` used to be silently forwarded to
87
+ * `solve.mjs` inside the spawned work session, where the failure was invisible
88
+ * in the chat. `/fix` now fails immediately, in the same chat message, using the
89
+ * very same checks `/solve` runs (`detectMalformedFlags` + solve's strict yargs
90
+ * config), so no typo can slip through.
91
+ *
92
+ * @param {string[]} args - Arguments as produced by `buildFixCommandArgs().args`.
93
+ * @returns {Promise<string|null>} Error message to show the user, or `null` when valid.
94
+ */
95
+ export async function validateFixCommandOptions(args) {
96
+ const list = Array.isArray(args) ? args : [];
97
+
98
+ const { malformed, errors } = detectMalformedFlags(list);
99
+ if (malformed.length > 0) return errors.join('\n');
100
+
101
+ // `--ci-de` is closer to `/fix`'s own `--ci-cd` than to anything solve knows,
102
+ // so check fix's own vocabulary first — otherwise the generic suggester points
103
+ // at unrelated solve options.
104
+ const partitioned = partitionFixArgs(list);
105
+ for (const arg of partitioned.passthrough) {
106
+ if (!arg.startsWith('-')) continue;
107
+ const name = arg.split('=')[0];
108
+ const closest = FIX_OWN_OPTIONS.map(option => ({ option, distance: calculateLevenshteinDistance(name, option) }))
109
+ .filter(candidate => candidate.distance > 0 && candidate.distance <= 2)
110
+ .sort((a, b) => a.distance - b.distance)[0];
111
+ if (closest) return `Unknown option "${name}". Did you mean "${closest.option}"?`;
112
+ }
113
+
114
+ // solve requires a positional issue URL; a placeholder keeps the parser happy
115
+ // so that only the *options* are judged here.
116
+ const probeArgs = ['https://github.com/owner/repo/issues/1', ...partitioned.passthrough];
117
+ try {
118
+ await parseArgsWithYargs(probeArgs, getLinoYargsFactory(), createSolveYargsConfig);
119
+ } catch (error) {
120
+ return error?.message || String(error);
121
+ }
122
+ return null;
123
+ }
124
+
71
125
  // Issue #378: inject --language LOCALE into spawn args if no language flag is
72
126
  // already present, so spawned fix sessions inherit the user's effective locale.
73
127
  function injectLanguageIfMissing(args, locale) {
@@ -81,7 +135,7 @@ function injectLanguageIfMissing(args, locale) {
81
135
  }
82
136
 
83
137
  export function registerFixCommand(bot, options) {
84
- const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, resolveLocale = null, solveOverrides = [] } = options;
138
+ const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply = defaultSafeReply, executeAndUpdateMessage, resolveLocale = null, solveOverrides = [] } = options;
85
139
 
86
140
  async function handleFixCommand(ctx) {
87
141
  const commandDisplay = '/fix';
@@ -95,7 +149,7 @@ export function registerFixCommand(bot, options) {
95
149
  });
96
150
 
97
151
  if (!fixEnabled) {
98
- await ctx.reply('āŒ The fix command is disabled on this bot instance.');
152
+ await safeReply(ctx, 'āŒ The fix command is disabled on this bot instance.');
99
153
  return;
100
154
  }
101
155
  if (isOldMessage(ctx)) return;
@@ -107,11 +161,11 @@ export function registerFixCommand(bot, options) {
107
161
  return;
108
162
  }
109
163
  if (!isGroupChat(ctx)) {
110
- await ctx.reply(`āŒ The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
164
+ await safeReply(ctx, `āŒ The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
111
165
  return;
112
166
  }
113
167
  if (!isTopicAuthorized(ctx)) {
114
- await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
168
+ await safeReply(ctx, buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
115
169
  return;
116
170
  }
117
171
  if (isChatStopped(ctx.chat.id)) {
@@ -131,6 +185,16 @@ export function registerFixCommand(bot, options) {
131
185
  return;
132
186
  }
133
187
 
188
+ // Issue #2166: fail immediately on any unsupported option, before a work
189
+ // session is spawned, so a typo can never turn into a silent no-op. Runs
190
+ // after --isolation extraction because that flag is consumed by /fix itself
191
+ // and is not part of the solve vocabulary the probe parser validates.
192
+ const optionsError = await validateFixCommandOptions(filteredArgs);
193
+ if (optionsError) {
194
+ await safeReply(ctx, `āŒ Invalid options: ${escapeMarkdown(optionsError)}\n\nUse /help to see available options`, { reply_to_message_id: ctx.message.message_id });
195
+ return;
196
+ }
197
+
134
198
  // Issue #2085: /fix hands the generated issue off to /solve, so it must
135
199
  // apply the operator's solve overrides (TELEGRAM_SOLVE_OVERRIDES) exactly
136
200
  // like the /solve handler does — otherwise the solve started by /fix runs
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { t, getSupportedLocales, normalizeLocale, setUserLocale, clearUserLocale, resolveLocaleFromTelegramCtx } from './i18n.lib.mjs';
14
+ import { safeReply } from './telegram-safe-reply.lib.mjs';
14
15
 
15
16
  export function registerLanguageCommand(bot, options = {}) {
16
17
  const { VERBOSE = false, isOldMessage, isForwardedOrReply } = options;
@@ -27,14 +28,14 @@ export function registerLanguageCommand(bot, options = {}) {
27
28
  const arg = parts.length > 1 ? parts[1] : null;
28
29
  if (!arg) {
29
30
  const langName = t(`language.${locale}`, {}, { locale });
30
- await ctx.reply(t('telegram.language_current', { language: langName, supported: supportedList }, { locale }), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
31
+ await safeReply(ctx, t('telegram.language_current', { language: langName, supported: supportedList }, { locale }), { reply_to_message_id: ctx.message.message_id });
31
32
  return;
32
33
  }
33
34
  if (['default', 'reset', 'clear'].includes(arg.toLowerCase())) {
34
35
  clearUserLocale(userId);
35
36
  const newLocale = resolveLocaleFromTelegramCtx(ctx);
36
37
  const langName = t(`language.${newLocale}`, {}, { locale: newLocale });
37
- await ctx.reply(t('telegram.language_set', { language: langName }, { locale: newLocale }), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
38
+ await safeReply(ctx, t('telegram.language_set', { language: langName }, { locale: newLocale }), { reply_to_message_id: ctx.message.message_id });
38
39
  return;
39
40
  }
40
41
  const target = normalizeLocale(arg);
@@ -44,6 +45,6 @@ export function registerLanguageCommand(bot, options = {}) {
44
45
  }
45
46
  setUserLocale(userId, target);
46
47
  const langName = t(`language.${target}`, {}, { locale: target });
47
- await ctx.reply(t('telegram.language_set', { language: langName }, { locale: target }), { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
48
+ await safeReply(ctx, t('telegram.language_set', { language: langName }, { locale: target }), { reply_to_message_id: ctx.message.message_id });
48
49
  });
49
50
  }