@link-assistant/hive-mind 2.13.0 → 2.13.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/src/buildUserMention.lib.mjs +30 -3
  4. package/src/claude.budget-stats.lib.mjs +4 -2
  5. package/src/claude.lib.mjs +31 -16
  6. package/src/claude.stream-events.lib.mjs +56 -2
  7. package/src/disk-guard.lib.mjs +256 -0
  8. package/src/github-url-parser.lib.mjs +26 -1
  9. package/src/github.batch.lib.mjs +31 -11
  10. package/src/github.lib.mjs +3 -1
  11. package/src/hive.mjs +97 -11
  12. package/src/lib.mjs +23 -3
  13. package/src/list-solution-drafts.lib.mjs +17 -4
  14. package/src/locales/en.lino +1 -0
  15. package/src/locales/hi.lino +1 -0
  16. package/src/locales/ru.lino +1 -0
  17. package/src/locales/zh.lino +1 -0
  18. package/src/session-log-rename.lib.mjs +65 -0
  19. package/src/session-monitor.lib.mjs +3 -2
  20. package/src/solve.mjs +12 -0
  21. package/src/solve.repository.lib.mjs +5 -1
  22. package/src/solve.restart-shared.lib.mjs +18 -9
  23. package/src/solve.results.lib.mjs +6 -3
  24. package/src/solve.validation.lib.mjs +7 -9
  25. package/src/telegram-accept-invitations.lib.mjs +5 -3
  26. package/src/telegram-bot.mjs +18 -9
  27. package/src/telegram-command-execution.lib.mjs +2 -1
  28. package/src/telegram-context-safety.lib.mjs +70 -0
  29. package/src/telegram-fix-command.lib.mjs +68 -4
  30. package/src/telegram-language-command.lib.mjs +4 -3
  31. package/src/telegram-log-command.lib.mjs +14 -12
  32. package/src/telegram-markdown-validator.lib.mjs +192 -0
  33. package/src/telegram-merge-command.lib.mjs +18 -16
  34. package/src/telegram-message-filters.lib.mjs +1 -1
  35. package/src/telegram-safe-reply.lib.mjs +290 -21
  36. package/src/telegram-solve-queue-command.lib.mjs +2 -1
  37. package/src/telegram-solve-queue.lib.mjs +16 -7
  38. package/src/telegram-start-stop-command.lib.mjs +38 -27
  39. package/src/telegram-subscribers.lib.mjs +6 -4
  40. package/src/telegram-terminal-watch-command.lib.mjs +8 -7
  41. package/src/telegram-tokens-command.lib.mjs +2 -1
  42. package/src/telegram-top-command.lib.mjs +8 -9
@@ -30,7 +30,10 @@ const fs = (await use('fs')).promises;
30
30
 
31
31
  // Import shared library functions
32
32
  const lib = await import('./lib.mjs');
33
- const { log, formatAligned, extractToolErrorCore } = lib;
33
+ // Issue #2160: the real log-file accessors must be forwarded to every tool executor. Passing
34
+ // no-op stubs (or omitting them entirely) broke session-log renaming in restart/watch iterations
35
+ // ("⚠️ Could not rename log file: getLogFile is not a function").
36
+ const { log, formatAligned, extractToolErrorCore, getLogFile, setLogFile } = lib;
34
37
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
35
38
  const { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } = await import('./ai-tool-scratch.lib.mjs');
36
39
  const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
@@ -240,8 +243,8 @@ export const executeToolIteration = async params => {
240
243
  log,
241
244
  formatAligned,
242
245
  getResourceSnapshot,
243
- setLogFile: () => {},
244
- getLogFile: () => '',
246
+ setLogFile,
247
+ getLogFile,
245
248
  $,
246
249
  });
247
250
  } else if (argv.tool === 'opencode') {
@@ -280,6 +283,8 @@ export const executeToolIteration = async params => {
280
283
  log,
281
284
  formatAligned,
282
285
  getResourceSnapshot,
286
+ setLogFile,
287
+ getLogFile,
283
288
  opencodePath,
284
289
  $,
285
290
  });
@@ -318,8 +323,8 @@ export const executeToolIteration = async params => {
318
323
  repo,
319
324
  argv,
320
325
  log,
321
- setLogFile: () => {},
322
- getLogFile: () => '',
326
+ setLogFile,
327
+ getLogFile,
323
328
  formatAligned,
324
329
  getResourceSnapshot,
325
330
  codexPath,
@@ -362,6 +367,8 @@ export const executeToolIteration = async params => {
362
367
  log,
363
368
  formatAligned,
364
369
  getResourceSnapshot,
370
+ setLogFile,
371
+ getLogFile,
365
372
  agentPath,
366
373
  $,
367
374
  });
@@ -400,8 +407,8 @@ export const executeToolIteration = async params => {
400
407
  repo,
401
408
  argv,
402
409
  log,
403
- setLogFile: () => {},
404
- getLogFile: () => '',
410
+ setLogFile,
411
+ getLogFile,
405
412
  formatAligned,
406
413
  getResourceSnapshot,
407
414
  geminiPath,
@@ -442,8 +449,8 @@ export const executeToolIteration = async params => {
442
449
  repo,
443
450
  argv,
444
451
  log,
445
- setLogFile: () => {},
446
- getLogFile: () => '',
452
+ setLogFile,
453
+ getLogFile,
447
454
  formatAligned,
448
455
  getResourceSnapshot,
449
456
  qwenPath,
@@ -488,6 +495,8 @@ export const executeToolIteration = async params => {
488
495
  log,
489
496
  formatAligned,
490
497
  getResourceSnapshot,
498
+ setLogFile,
499
+ getLogFile,
491
500
  claudePath,
492
501
  $,
493
502
  });
@@ -485,12 +485,15 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
485
485
  const verifyResult = await $({ cwd: tempDir })`git ls-files ${fileName} 2>&1`;
486
486
  const fileStillExists = verifyResult.code === 0 && verifyResult.stdout && verifyResult.stdout.trim();
487
487
  if (fileStillExists) {
488
- await log(` ⚠️ WARNING: ${fileName} still exists after cleanup attempting direct removal...`);
489
- // Check if the file existed before the initial commit (parent)
488
+ // Issue #2160: the pre-existence check must come FIRST. A file that legitimately predates
489
+ // the session is not a cleanup failure, and warning about it produced a false positive
490
+ // ("⚠️ WARNING: .gitkeep still exists after cleanup" immediately followed by
491
+ // "ℹ️ .gitkeep existed before this session — keeping pre-existing file").
490
492
  const parentCommit = `${claudeCommitHash}~1`;
491
493
  const parentFileExists = await $({ cwd: tempDir })`git cat-file -e ${parentCommit}:${fileName} 2>&1`;
492
494
  if (parentFileExists.code !== 0) {
493
- // File didn't exist before the session — force remove it
495
+ // File didn't exist before the session — this is a real leftover, force remove it
496
+ await log(` ⚠️ WARNING: ${fileName} still exists after cleanup — attempting direct removal...`);
494
497
  await $({ cwd: tempDir })`git rm -f ${fileName} 2>&1`;
495
498
  const fallbackCommit = await $({ cwd: tempDir })`git commit -m "Remove leftover ${fileName} (post-cleanup fallback, Issue #1436)" 2>&1`;
496
499
  if (fallbackCommit.code === 0) {
@@ -53,12 +53,6 @@ const { parseResetTime: parseResetTimeToDate } = usageLimitLib;
53
53
 
54
54
  const { validateClaudeConnection } = claudeLib;
55
55
 
56
- // Wrapper function for disk space check using imported module
57
- const checkDiskSpace = async (minSpaceMB = 10240) => {
58
- const result = await memoryCheck.checkDiskSpace(minSpaceMB, { log });
59
- return result.success;
60
- };
61
-
62
56
  // Wrapper function for memory check using imported module
63
57
  const checkMemory = async (minMemoryMB = 256) => {
64
58
  const result = await memoryCheck.checkMemory(minMemoryMB, { log });
@@ -217,9 +211,13 @@ export const validateContinueOnlyOnFeedback = async (argv, isPrUrl, isIssueUrl)
217
211
  // Note: skipToolConnection only skips the connection check, not model validation
218
212
  // Model validation should be done separately before calling this function
219
213
  export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnection = false, model = 'sonnet', argv = {}) => {
220
- // Check disk space before proceeding
221
- const hasEnoughSpace = await checkDiskSpace(minDiskSpace);
222
- if (!hasEnoughSpace) {
214
+ // Check disk space before proceeding.
215
+ // Issue #2160: record *which* check failed on argv. A full disk says nothing about the issue
216
+ // being solved, so the caller exits with a retry-later code and skips the "Solution Draft
217
+ // Failed" comment instead of blaming the task (hive counted 4 such exits as task failures).
218
+ const diskSpace = await memoryCheck.checkDiskSpace(minDiskSpace, { log });
219
+ if (!diskSpace.success) {
220
+ argv.systemCheckFailure = { check: 'disk-space', availableMB: diskSpace.availableMB, requiredMB: minDiskSpace };
223
221
  return false;
224
222
  }
225
223
 
@@ -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
  }
@@ -25,6 +25,7 @@ import os from 'os';
25
25
  import fs from 'fs/promises';
26
26
  import { constants as fsConstants } from 'fs';
27
27
  import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
28
+ import { safeReply, safeReplyWithDocument, safeSendDocument } from './telegram-safe-reply.lib.mjs';
28
29
 
29
30
  const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
30
31
  const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
@@ -212,9 +213,9 @@ export async function registerLogCommand(bot, options) {
212
213
  const sessionId = directSessionId || replySessionId;
213
214
 
214
215
  if (!sessionId) {
215
- await ctx.reply('❌ /log requires a session id.\n\nUsage:\n• `/log <UUID>` — fetch a specific session log\n• Reply to a session message with `/log` — fetch the session referenced in that message', {
216
- parse_mode: 'Markdown',
216
+ await safeReply(ctx, '❌ /log requires a session id.\n\nUsage:\n• `/log <UUID>` — fetch a specific session log\n• Reply to a session message with `/log` — fetch the session referenced in that message', {
217
217
  reply_to_message_id: message.message_id,
218
+ verbose: VERBOSE,
218
219
  });
219
220
  return;
220
221
  }
@@ -261,9 +262,9 @@ export async function registerLogCommand(bot, options) {
261
262
  }
262
263
 
263
264
  if (!statusResult || !statusResult.exists) {
264
- await ctx.reply(`❌ Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`📊 Session: <uuid>\` line in one of the bot's status messages.`, {
265
- parse_mode: 'Markdown',
265
+ await safeReply(ctx, `❌ Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`📊 Session: <uuid>\` line in one of the bot's status messages.`, {
266
266
  reply_to_message_id: message.message_id,
267
+ verbose: VERBOSE,
267
268
  });
268
269
  return;
269
270
  }
@@ -305,17 +306,17 @@ export async function registerLogCommand(bot, options) {
305
306
  return;
306
307
  }
307
308
  if (!(await fileExists(logPath))) {
308
- await ctx.reply(`❌ Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
309
- parse_mode: 'Markdown',
309
+ await safeReply(ctx, `❌ Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
310
310
  reply_to_message_id: message.message_id,
311
+ verbose: VERBOSE,
311
312
  });
312
313
  return;
313
314
  }
314
315
  const size = await fileSize(logPath);
315
316
  if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
316
- await ctx.reply(`❌ Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
317
- parse_mode: 'Markdown',
317
+ await safeReply(ctx, `❌ Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
318
318
  reply_to_message_id: message.message_id,
319
+ verbose: VERBOSE,
319
320
  });
320
321
  return;
321
322
  }
@@ -333,7 +334,7 @@ export async function registerLogCommand(bot, options) {
333
334
  let upload;
334
335
  try {
335
336
  upload = await prepareSanitizedLogUpload(logPath, caption);
336
- await ctx.replyWithDocument({ source: upload.path, filename }, { reply_to_message_id: message.message_id, caption: upload.caption, parse_mode: 'Markdown' });
337
+ await safeReplyWithDocument(ctx, { source: upload.path, filename }, { reply_to_message_id: message.message_id, caption: upload.caption, parse_mode: 'Markdown', verbose: VERBOSE });
337
338
  } catch (error) {
338
339
  console.error('[ERROR] /log: replyWithDocument failed:', error);
339
340
  await ctx.reply('❌ Failed to sanitize or upload the log.', { reply_to_message_id: message.message_id });
@@ -382,7 +383,8 @@ export async function registerLogCommand(bot, options) {
382
383
  upload = await prepareSanitizedLogUpload(logPath, caption);
383
384
  const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
384
385
  replyOpts.caption = upload.caption;
385
- await ctx.telegram.sendDocument(userId, { source: upload.path, filename }, replyOpts);
386
+ replyOpts.verbose = VERBOSE;
387
+ await safeSendDocument(ctx.telegram, userId, { source: upload.path, filename }, replyOpts);
386
388
  } catch (error) {
387
389
  console.error('[ERROR] /log: sendDocument to DM failed:', error);
388
390
  // Tell the user, in their original chat, that DM delivery failed
@@ -397,9 +399,9 @@ export async function registerLogCommand(bot, options) {
397
399
  // Acknowledge in the original chat (only if it wasn't already a DM).
398
400
  if (chatType !== 'private') {
399
401
  try {
400
- await ctx.reply(`📬 Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
401
- parse_mode: 'Markdown',
402
+ await safeReply(ctx, `📬 Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
402
403
  reply_to_message_id: message.message_id,
404
+ verbose: VERBOSE,
403
405
  });
404
406
  } catch (error) {
405
407
  console.error('[ERROR] /log: failed to acknowledge in chat:', error);