@link-assistant/hive-mind 1.69.10 → 1.69.12

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.
@@ -1,19 +1,122 @@
1
- // Issue #1460/#1497: safeReply - try Markdown first, fall back to plain text on parsing errors.
1
+ import { normalizeLocale, t } from './i18n.lib.mjs';
2
+
3
+ const FORMATTING_FALLBACK_INSTALLED = Symbol.for('hiveMind.telegramFormattingFallbackInstalled');
4
+ const DEFAULT_FORMATTING_FALLBACK_WARNING = '⚠️ Formatting error detected. Showing plain text fallback.';
5
+ const FORMATTING_FALLBACK_WARNINGS = {
6
+ en: DEFAULT_FORMATTING_FALLBACK_WARNING,
7
+ ru: '⚠️ Обнаружена ошибка форматирования. Показываю обычный текст.',
8
+ zh: '⚠️ 检测到格式错误。正在显示纯文本备用内容。',
9
+ hi: '⚠️ फ़ॉर्मैटिंग त्रुटि मिली। सादा पाठ fallback दिखाया जा रहा है।',
10
+ };
11
+
12
+ function splitOptions(options = {}) {
13
+ const { fallbackLocale, locale, verbose, ...telegramOptions } = options || {};
14
+ return {
15
+ telegramOptions,
16
+ fallbackLocale: fallbackLocale || locale || null,
17
+ verbose: Boolean(verbose),
18
+ };
19
+ }
20
+
21
+ export function isTelegramFormattingError(error) {
22
+ const message = error?.description || error?.message || String(error || '');
23
+ return /can't parse entities/i.test(message) || /can't find end of/i.test(message) || /entity.*parse/i.test(message) || (/bad request/i.test(message) && /400|parse|entity/i.test(message));
24
+ }
25
+
26
+ export function stripTelegramMarkdown(text) {
27
+ return String(text ?? '')
28
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
29
+ .replace(/```([\s\S]*?)```/g, '$1')
30
+ .replace(/\\_/g, '_')
31
+ .replace(/\\\*/g, '*')
32
+ .replace(/\\`/g, '`')
33
+ .replace(/\\\[/g, '[')
34
+ .replace(/\*([^*\n]+)\*/g, '$1')
35
+ .replace(/_([^_\n]+)_/g, '$1')
36
+ .replace(/`([^`]+)`/g, '$1');
37
+ }
38
+
39
+ function getFormattingFallbackWarning(locale) {
40
+ const key = 'telegram.formatting_fallback_warning';
41
+ const normalizedLocale = normalizeLocale(locale);
42
+ const warning = t(key, {}, locale ? { locale } : {});
43
+ if (warning !== key && (normalizedLocale === 'en' || warning !== DEFAULT_FORMATTING_FALLBACK_WARNING)) return warning;
44
+ return FORMATTING_FALLBACK_WARNINGS[normalizedLocale] || DEFAULT_FORMATTING_FALLBACK_WARNING;
45
+ }
46
+
47
+ export function buildTelegramFormattingFallbackText(text, options = {}) {
48
+ const locale = options?.fallbackLocale || options?.locale || null;
49
+ return `${getFormattingFallbackWarning(locale)}\n\n${stripTelegramMarkdown(text)}`;
50
+ }
51
+
52
+ function logFormattingFailure(scope, error, text, verbose = false) {
53
+ const message = error?.description || error?.message || String(error || '');
54
+ console.error(`[telegram-bot] ${scope}: formatted Telegram message failed: ${message}`);
55
+ if (verbose) {
56
+ console.error(`[telegram-bot] ${scope}: Failing message (${Buffer.byteLength(String(text ?? ''), 'utf-8')} bytes): ${text}`);
57
+ }
58
+ }
59
+
60
+ // Issue #1460/#1497/#1788: try Markdown first, fall back to localized plain text on parsing errors.
2
61
  export async function safeReply(ctx, text, options = {}) {
62
+ const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
63
+ const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
3
64
  try {
4
- return await ctx.reply(text, { parse_mode: 'Markdown', ...options });
65
+ return await ctx.reply(text, firstOptions);
5
66
  } catch (error) {
6
- const message = error?.message || '';
7
- const isParsingError = message.includes("can't parse entities") || message.includes("Can't parse entities") || message.includes("can't find end of") || (message.includes('Bad Request') && message.includes('400'));
8
- if (!isParsingError) throw error;
9
- console.error(`[telegram-bot] safeReply: Markdown parsing failed: ${message}`);
10
- console.error(`[telegram-bot] safeReply: Failing message (${Buffer.byteLength(text, 'utf-8')} bytes): ${text}`);
11
- const plainText = text
12
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
13
- .replace(/\\_/g, '_')
14
- .replace(/\\\*/g, '*')
15
- .replace(/\*([^*]+)\*/g, '$1')
16
- .replace(/`([^`]+)`/g, '$1');
17
- return await ctx.reply(plainText, { ...options, parse_mode: undefined });
67
+ if (!isTelegramFormattingError(error)) throw error;
68
+ logFormattingFailure('safeReply', error, text, verbose);
69
+ const fallbackText = buildTelegramFormattingFallbackText(text, { fallbackLocale });
70
+ return await ctx.reply(fallbackText, { ...telegramOptions, parse_mode: undefined });
18
71
  }
19
72
  }
73
+
74
+ export async function safeEditMessageText(telegram, chatId, messageId, inlineMessageId, text, options = {}) {
75
+ const { telegramOptions, fallbackLocale, verbose } = splitOptions(options);
76
+ const firstOptions = { parse_mode: 'Markdown', ...telegramOptions };
77
+ try {
78
+ return await telegram.editMessageText(chatId, messageId, inlineMessageId, text, firstOptions);
79
+ } catch (error) {
80
+ if (!isTelegramFormattingError(error)) throw error;
81
+ logFormattingFailure('safeEditMessageText', error, text, verbose);
82
+ const fallbackText = buildTelegramFormattingFallbackText(text, { fallbackLocale });
83
+ return await telegram.editMessageText(chatId, messageId, inlineMessageId, fallbackText, { ...telegramOptions, parse_mode: undefined });
84
+ }
85
+ }
86
+
87
+ function wrapTelegramMethod(telegram, methodName, textIndex, optionsIndex, defaults = {}) {
88
+ const original = telegram?.[methodName];
89
+ if (typeof original !== 'function') return;
90
+
91
+ telegram[methodName] = async function wrappedTelegramMessageMethod(...args) {
92
+ const text = args[textIndex];
93
+ const originalOptions = args[optionsIndex] || {};
94
+ const { telegramOptions, fallbackLocale, verbose } = splitOptions(originalOptions);
95
+ args[optionsIndex] = telegramOptions;
96
+
97
+ try {
98
+ return await original.apply(this, args);
99
+ } catch (error) {
100
+ if (!isTelegramFormattingError(error) || typeof text !== 'string') throw error;
101
+ logFormattingFailure(methodName, error, text, verbose || defaults.verbose);
102
+ const retryArgs = [...args];
103
+ retryArgs[textIndex] = buildTelegramFormattingFallbackText(text, { fallbackLocale: fallbackLocale || defaults.fallbackLocale });
104
+ retryArgs[optionsIndex] = { ...telegramOptions, parse_mode: undefined };
105
+ return await original.apply(this, retryArgs);
106
+ }
107
+ };
108
+ }
109
+
110
+ export function installTelegramFormattingFallback(telegram, options = {}) {
111
+ if (!telegram || telegram[FORMATTING_FALLBACK_INSTALLED]) return telegram;
112
+
113
+ const defaults = {
114
+ fallbackLocale: options.fallbackLocale || options.locale || null,
115
+ verbose: Boolean(options.verbose),
116
+ };
117
+
118
+ wrapTelegramMethod(telegram, 'sendMessage', 1, 2, defaults);
119
+ wrapTelegramMethod(telegram, 'editMessageText', 3, 4, defaults);
120
+ telegram[FORMATTING_FALLBACK_INSTALLED] = true;
121
+ return telegram;
122
+ }
@@ -13,6 +13,15 @@
13
13
  * @see https://github.com/link-assistant/hive-mind/issues/1232
14
14
  */
15
15
 
16
+ import { t } from './i18n.lib.mjs';
17
+
18
+ const GROUP_ONLY_MESSAGE = '❌ The /solve_queue command only works in group chats. Please add this bot to a group and make it an admin.';
19
+
20
+ function commandText(key, params = {}, locale = null, fallback = key) {
21
+ const translated = t(key, params, locale ? { locale } : {});
22
+ return translated === key ? fallback : translated;
23
+ }
24
+
16
25
  /**
17
26
  * Registers the /solve_queue command handler with the bot
18
27
  * @param {Object} bot - The Telegraf bot instance
@@ -29,7 +38,7 @@
29
38
  * @returns {{ handleSolveQueueCommand: Function }} The command handler for use in text fallback
30
39
  */
31
40
  export function registerSolveQueueCommand(bot, options) {
32
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, getSolveQueue } = options;
41
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, getSolveQueue, safeReply, resolveLocale } = options;
33
42
 
34
43
  async function handleSolveQueueCommand(ctx) {
35
44
  VERBOSE && console.log('[VERBOSE] /solve_queue command received');
@@ -40,6 +49,8 @@ export function registerSolveQueueCommand(bot, options) {
40
49
  level: 'info',
41
50
  data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
42
51
  });
52
+ const locale = resolveLocale ? resolveLocale(ctx) : null;
53
+ const replyWithFallback = (text, replyOptions = {}) => (safeReply ? safeReply(ctx, text, replyOptions) : ctx.reply(text, { parse_mode: 'Markdown', ...replyOptions }));
43
54
 
44
55
  // Ignore messages sent before bot started
45
56
  if (isOldMessage(ctx)) {
@@ -55,8 +66,9 @@ export function registerSolveQueueCommand(bot, options) {
55
66
 
56
67
  if (!isGroupChat(ctx)) {
57
68
  VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not a group chat');
58
- await ctx.reply('❌ The /solve_queue command only works in group chats. Please add this bot to a group and make it an admin.', {
69
+ await replyWithFallback(commandText('telegram.solve_queue_only_in_groups', {}, locale, GROUP_ONLY_MESSAGE), {
59
70
  reply_to_message_id: ctx.message.message_id,
71
+ fallbackLocale: locale,
60
72
  });
61
73
  return;
62
74
  }
@@ -65,7 +77,7 @@ export function registerSolveQueueCommand(bot, options) {
65
77
  if (!authorize(ctx)) {
66
78
  VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not authorized');
67
79
  const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
68
- await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
80
+ await replyWithFallback(errMsg, { reply_to_message_id: ctx.message.message_id, fallbackLocale: locale });
69
81
  return;
70
82
  }
71
83
 
@@ -77,11 +89,11 @@ export function registerSolveQueueCommand(bot, options) {
77
89
  // Shows per-queue breakdown with first 5 items per queue and human-readable times
78
90
  // Processing counts are actual running system processes (via pgrep)
79
91
  // See: https://github.com/link-assistant/hive-mind/issues/1267
80
- const message = await solveQueue.formatDetailedStatus();
92
+ const message = await solveQueue.formatDetailedStatus({ locale });
81
93
 
82
- await ctx.reply(message, {
83
- parse_mode: 'Markdown',
94
+ await replyWithFallback(message, {
84
95
  reply_to_message_id: ctx.message.message_id,
96
+ fallbackLocale: locale,
85
97
  });
86
98
  }
87
99
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { exec } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
+ import { lt } from './limits-i18n.lib.mjs';
5
6
 
6
7
  const execAsync = promisify(exec);
7
8
 
@@ -111,8 +112,9 @@ export function formatThresholdPercent(ratio) {
111
112
  * @returns {string} Human-readable duration
112
113
  * @see https://github.com/link-assistant/hive-mind/issues/1267
113
114
  */
114
- export function formatDuration(ms) {
115
+ export function formatDuration(ms, options = {}) {
115
116
  if (ms < 0) ms = 0;
117
+ const locale = typeof options === 'string' ? options : options?.locale || null;
116
118
 
117
119
  const totalSeconds = Math.floor(ms / 1000);
118
120
  const days = Math.floor(totalSeconds / 86400);
@@ -120,11 +122,26 @@ export function formatDuration(ms) {
120
122
  const minutes = Math.floor((totalSeconds % 3600) / 60);
121
123
  const seconds = totalSeconds % 60;
122
124
 
125
+ const labels =
126
+ locale && locale !== 'en'
127
+ ? {
128
+ day: lt('duration_day_short', {}, { locale }),
129
+ hour: lt('duration_hour_short', {}, { locale }),
130
+ minute: lt('duration_minute_short', {}, { locale }),
131
+ second: lt('duration_second_short', {}, { locale }),
132
+ }
133
+ : {
134
+ day: 'd',
135
+ hour: 'h',
136
+ minute: 'm',
137
+ second: 's',
138
+ };
139
+
123
140
  const parts = [];
124
- if (days > 0) parts.push(`${days}d`);
125
- if (hours > 0) parts.push(`${hours}h`);
126
- if (minutes > 0) parts.push(`${minutes}m`);
127
- if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
141
+ if (days > 0) parts.push(`${days}${locale && locale !== 'en' ? ' ' : ''}${labels.day}`);
142
+ if (hours > 0) parts.push(`${hours}${locale && locale !== 'en' ? ' ' : ''}${labels.hour}`);
143
+ if (minutes > 0) parts.push(`${minutes}${locale && locale !== 'en' ? ' ' : ''}${labels.minute}`);
144
+ if (seconds > 0 || parts.length === 0) parts.push(`${seconds}${locale && locale !== 'en' ? ' ' : ''}${labels.second}`);
128
145
 
129
146
  return parts.join(' ');
130
147
  }
@@ -136,9 +153,44 @@ export function formatDuration(ms) {
136
153
  * @param {number} threshold - Threshold ratio (0.0 - 1.0)
137
154
  * @returns {string} Human-readable reason
138
155
  */
139
- export function formatWaitingReason(metric, currentValue, threshold) {
156
+ export function formatWaitingReason(metric, currentValue, threshold, options = {}) {
157
+ const locale = typeof options === 'string' ? options : options?.locale || null;
140
158
  const thresholdPercent = formatThresholdPercent(threshold);
141
159
  const currentPercent = Math.round(currentValue);
160
+ const params = { currentPercent, thresholdPercent, metric };
161
+
162
+ if (locale && locale !== 'en') {
163
+ switch (metric) {
164
+ case 'ram':
165
+ return lt('reason_ram_usage', params, { locale });
166
+ case 'cpu':
167
+ return lt('reason_cpu_usage', params, { locale });
168
+ case 'disk':
169
+ return lt('reason_disk_usage', params, { locale });
170
+ case 'claude_5_hour_session':
171
+ return lt('reason_claude_5_hour_session', params, { locale });
172
+ case 'claude_weekly':
173
+ return lt('reason_claude_weekly', params, { locale });
174
+ case 'codex_5_hour_session':
175
+ return lt('reason_codex_5_hour_session', params, { locale });
176
+ case 'codex_weekly':
177
+ return lt('reason_codex_weekly', params, { locale });
178
+ case 'github':
179
+ return lt('reason_github_api', params, { locale });
180
+ case 'min_interval':
181
+ return lt('reason_min_interval', params, { locale });
182
+ case 'claude_running':
183
+ return lt('reason_claude_running', params, { locale });
184
+ case 'codex_running':
185
+ return lt('reason_codex_running', params, { locale });
186
+ case 'qwen_running':
187
+ return lt('reason_qwen_running', params, { locale });
188
+ case 'gemini_running':
189
+ return lt('reason_gemini_running', params, { locale });
190
+ default:
191
+ return lt('reason_threshold_exceeded', params, { locale });
192
+ }
193
+ }
142
194
 
143
195
  switch (metric) {
144
196
  case 'ram':
@@ -22,6 +22,7 @@ export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
22
22
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
23
23
  import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
24
24
  import { t } from './i18n.lib.mjs';
25
+ import { lt } from './limits-i18n.lib.mjs';
25
26
 
26
27
  export const QueueItemStatus = {
27
28
  QUEUED: 'queued',
@@ -32,6 +33,23 @@ export const QueueItemStatus = {
32
33
  CANCELLED: 'cancelled',
33
34
  };
34
35
 
36
+ function getLocale(options = {}) {
37
+ if (typeof options === 'string') return options;
38
+ return options?.locale || null;
39
+ }
40
+
41
+ function appendWaitingForCurrentCommand(reason, locale) {
42
+ return `${reason} (${lt('queue_waiting_current_command', {}, { locale })})`;
43
+ }
44
+
45
+ function appendRemainingDuration(reason, ms, locale) {
46
+ return `${reason} (${lt('remaining', { duration: formatDuration(ms, { locale }) }, { locale })})`;
47
+ }
48
+
49
+ function queueStatusLabel(status, locale) {
50
+ return lt(`queue_status_${status}`, {}, { locale });
51
+ }
52
+
35
53
  /**
36
54
  * Queue item representing a /solve command request
37
55
  */
@@ -373,7 +391,7 @@ export class SolveQueue {
373
391
  if (toolQueue.length === 0) continue;
374
392
 
375
393
  // Check if first item in this tool's queue can start
376
- const check = await this.canStartCommand({ tool });
394
+ const check = await this.canStartCommand({ tool, locale: toolQueue[0]?.locale || null });
377
395
 
378
396
  // When a 'reject' strategy threshold is exceeded, immediately reject
379
397
  // all items in this tool's queue instead of leaving them waiting.
@@ -411,16 +429,16 @@ export class SolveQueue {
411
429
  * @see https://github.com/link-assistant/hive-mind/issues/1555
412
430
  */
413
431
  async rejectAllItemsInQueue(tool, toolQueue, rejectReason) {
414
- const reason = rejectReason || 'Resource limit exceeded';
415
432
  while (toolQueue.length > 0) {
416
433
  const item = toolQueue.shift();
434
+ const reason = rejectReason || lt('resource_limit_exceeded', {}, { locale: item.locale });
417
435
  item.setFailed(reason);
418
436
  this.failed.push(item);
419
437
  this.stats.totalFailed++;
420
438
 
421
439
  this.log(`Rejected queued item: ${item.toString()} from ${tool} queue - ${reason}`);
422
440
 
423
- await this.updateItemMessage(item, `❌ Solve command rejected.\n\n${item.infoBlock}\n\n🚫 Reason: ${reason}`);
441
+ await this.updateItemMessage(item, t('telegram.solve_rejected', { infoBlock: item.infoBlock, reason }, { locale: item.locale }));
424
442
  }
425
443
  while (this.failed.length > 100) this.failed.shift();
426
444
  }
@@ -546,6 +564,7 @@ export class SolveQueue {
546
564
  */
547
565
  async canStartCommand(options = {}) {
548
566
  const tool = options.tool || 'claude';
567
+ const locale = getLocale(options);
549
568
  const reasons = [];
550
569
  let oneAtATime = false;
551
570
  let rejected = false;
@@ -558,8 +577,8 @@ export class SolveQueue {
558
577
  if (lastStartTime) {
559
578
  const timeSinceLastStart = Date.now() - lastStartTime;
560
579
  if (timeSinceLastStart < QUEUE_CONFIG.MIN_START_INTERVAL_MS) {
561
- const waitSeconds = Math.ceil((QUEUE_CONFIG.MIN_START_INTERVAL_MS - timeSinceLastStart) / 1000);
562
- reasons.push(formatWaitingReason('min_interval', 0, 0) + ` (${waitSeconds}s remaining)`);
580
+ const waitMs = QUEUE_CONFIG.MIN_START_INTERVAL_MS - timeSinceLastStart;
581
+ reasons.push(appendRemainingDuration(formatWaitingReason('min_interval', 0, 0, { locale }), waitMs, locale));
563
582
  this.recordThrottle('min_interval');
564
583
  }
565
584
  }
@@ -609,7 +628,7 @@ export class SolveQueue {
609
628
  // System resources apply to ALL tools, not just Claude
610
629
  // See: https://github.com/link-assistant/hive-mind/issues/1155
611
630
  // See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
612
- const resourceCheck = await this.checkSystemResources(totalProcessing);
631
+ const resourceCheck = await this.checkSystemResources(totalProcessing, { locale });
613
632
  if (resourceCheck.rejected) {
614
633
  rejected = true;
615
634
  rejectReason = resourceCheck.rejectReason;
@@ -628,7 +647,7 @@ export class SolveQueue {
628
647
  // See: https://github.com/link-assistant/hive-mind/issues/1253 (strategies)
629
648
  const hasRunningToolProcess = (externalProcessing.byTool[tool] || 0) > 0;
630
649
  const toolProcessingCount = this.getProcessingCountByTool(tool);
631
- const limitCheck = await this.checkApiLimits(hasRunningToolProcess, toolProcessingCount, tool);
650
+ const limitCheck = await this.checkApiLimits(hasRunningToolProcess, toolProcessingCount, tool, { locale });
632
651
  if (limitCheck.rejected) {
633
652
  rejected = true;
634
653
  rejectReason = limitCheck.rejectReason;
@@ -646,16 +665,16 @@ export class SolveQueue {
646
665
  // Add claude_running info at the END (not beginning) of reasons
647
666
  // Since it's supplementary info, not the primary blocking reason
648
667
  // See: https://github.com/link-assistant/hive-mind/issues/1078
649
- reasons.push(formatWaitingReason('claude_running', claudeProcessCount, 0) + ` (${claudeProcessCount} processes)`);
668
+ reasons.push(`${formatWaitingReason('claude_running', claudeProcessCount, 0, { locale })} (${lt('queue_processes', { count: claudeProcessCount }, { locale })})`);
650
669
  }
651
670
  if (tool === 'codex' && hasRunningCodex && reasons.length > 0) {
652
- reasons.push(formatWaitingReason('codex_running', codexProcessCount, 0) + ` (${codexProcessCount} processes)`);
671
+ reasons.push(`${formatWaitingReason('codex_running', codexProcessCount, 0, { locale })} (${lt('queue_processes', { count: codexProcessCount }, { locale })})`);
653
672
  }
654
673
  if (tool === 'qwen' && hasRunningQwen && reasons.length > 0) {
655
- reasons.push(formatWaitingReason('qwen_running', qwenProcessCount, 0) + ` (${qwenProcessCount} processes)`);
674
+ reasons.push(`${formatWaitingReason('qwen_running', qwenProcessCount, 0, { locale })} (${lt('queue_processes', { count: qwenProcessCount }, { locale })})`);
656
675
  }
657
676
  if (tool === 'gemini' && hasRunningGemini && reasons.length > 0) {
658
- reasons.push(formatWaitingReason('gemini_running', geminiProcessCount, 0) + ` (${geminiProcessCount} processes)`);
677
+ reasons.push(`${formatWaitingReason('gemini_running', geminiProcessCount, 0, { locale })} (${lt('queue_processes', { count: geminiProcessCount }, { locale })})`);
659
678
  }
660
679
 
661
680
  const canStart = reasons.length === 0 && !rejected;
@@ -712,7 +731,8 @@ export class SolveQueue {
712
731
  * @param {number} totalProcessing - Total processing count (queue + external claude processes)
713
732
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
714
733
  */
715
- async checkSystemResources(totalProcessing = 0) {
734
+ async checkSystemResources(totalProcessing = 0, options = {}) {
735
+ const locale = getLocale(options);
716
736
  const reasons = [];
717
737
  let oneAtATime = false;
718
738
  let rejected = false;
@@ -723,7 +743,7 @@ export class SolveQueue {
723
743
  if (memResult.success) {
724
744
  const usedRatio = memResult.memory.usedPercentage / 100;
725
745
  if (usedRatio >= QUEUE_CONFIG.thresholds.ram.value) {
726
- const reason = formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.thresholds.ram.value);
746
+ const reason = formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.thresholds.ram.value, { locale });
727
747
  const strategy = QUEUE_CONFIG.thresholds.ram.strategy;
728
748
  this.recordThrottle(`ram_${strategy}`);
729
749
 
@@ -733,7 +753,7 @@ export class SolveQueue {
733
753
  } else if (strategy === 'dequeue-one-at-a-time') {
734
754
  oneAtATime = true;
735
755
  if (totalProcessing > 0) {
736
- reasons.push(reason + ' (waiting for current command)');
756
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
737
757
  }
738
758
  } else {
739
759
  // 'enqueue' - block unconditionally
@@ -760,7 +780,7 @@ export class SolveQueue {
760
780
  }
761
781
 
762
782
  if (usageRatio >= QUEUE_CONFIG.thresholds.cpu.value) {
763
- const reason = formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.thresholds.cpu.value);
783
+ const reason = formatWaitingReason('cpu', usagePercent, QUEUE_CONFIG.thresholds.cpu.value, { locale });
764
784
  const strategy = QUEUE_CONFIG.thresholds.cpu.strategy;
765
785
  this.recordThrottle(`cpu_${strategy}`);
766
786
 
@@ -770,7 +790,7 @@ export class SolveQueue {
770
790
  } else if (strategy === 'dequeue-one-at-a-time') {
771
791
  oneAtATime = true;
772
792
  if (totalProcessing > 0) {
773
- reasons.push(reason + ' (waiting for current command)');
793
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
774
794
  }
775
795
  } else {
776
796
  // 'enqueue' - block unconditionally
@@ -788,7 +808,7 @@ export class SolveQueue {
788
808
  const usedPercent = 100 - diskResult.diskSpace.freePercentage;
789
809
  const usedRatio = usedPercent / 100;
790
810
  if (usedRatio >= QUEUE_CONFIG.thresholds.disk.value) {
791
- const reason = formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.thresholds.disk.value);
811
+ const reason = formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.thresholds.disk.value, { locale });
792
812
  const strategy = QUEUE_CONFIG.thresholds.disk.strategy;
793
813
  this.recordThrottle(`disk_${strategy}`);
794
814
 
@@ -798,7 +818,7 @@ export class SolveQueue {
798
818
  } else if (strategy === 'dequeue-one-at-a-time') {
799
819
  oneAtATime = true;
800
820
  if (totalProcessing > 0) {
801
- reasons.push(reason + ' (waiting for current command)');
821
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
802
822
  }
803
823
  } else {
804
824
  // 'enqueue' - block unconditionally
@@ -833,7 +853,8 @@ export class SolveQueue {
833
853
  * @param {string} tool - The tool being used ('claude', 'agent', 'codex', 'gemini', 'qwen', etc.)
834
854
  * @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean, rejected: boolean, rejectReason: string|null}>}
835
855
  */
836
- async checkApiLimits(hasRunningToolProcess = false, toolProcessingCount = 0, tool = 'claude') {
856
+ async checkApiLimits(hasRunningToolProcess = false, toolProcessingCount = 0, tool = 'claude', options = {}) {
857
+ const locale = getLocale(options);
837
858
  const reasons = [];
838
859
  let oneAtATime = false;
839
860
  let rejected = false;
@@ -862,7 +883,7 @@ export class SolveQueue {
862
883
  if (sessionPercent !== null) {
863
884
  const sessionRatio = sessionPercent / 100;
864
885
  if (sessionRatio >= QUEUE_CONFIG.thresholds.claude5Hour.value) {
865
- const reason = formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.claude5Hour.value);
886
+ const reason = formatWaitingReason('claude_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.claude5Hour.value, { locale });
866
887
  const strategy = QUEUE_CONFIG.thresholds.claude5Hour.strategy;
867
888
  this.recordThrottle(sessionRatio >= 1.0 ? 'claude_5_hour_session_100' : `claude_5_hour_session_${strategy}`);
868
889
 
@@ -872,7 +893,7 @@ export class SolveQueue {
872
893
  } else if (strategy === 'dequeue-one-at-a-time') {
873
894
  oneAtATime = true;
874
895
  if (totalToolProcessing > 0) {
875
- reasons.push(reason + ' (waiting for current command)');
896
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
876
897
  }
877
898
  } else {
878
899
  // 'enqueue' - block unconditionally
@@ -887,7 +908,7 @@ export class SolveQueue {
887
908
  if (weeklyPercent !== null) {
888
909
  const weeklyRatio = weeklyPercent / 100;
889
910
  if (weeklyRatio >= QUEUE_CONFIG.thresholds.claudeWeekly.value) {
890
- const reason = formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.claudeWeekly.value);
911
+ const reason = formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.claudeWeekly.value, { locale });
891
912
  const strategy = QUEUE_CONFIG.thresholds.claudeWeekly.strategy;
892
913
  this.recordThrottle(weeklyRatio >= 1.0 ? 'claude_weekly_100' : `claude_weekly_${strategy}`);
893
914
 
@@ -897,7 +918,7 @@ export class SolveQueue {
897
918
  } else if (strategy === 'dequeue-one-at-a-time') {
898
919
  oneAtATime = true;
899
920
  if (totalToolProcessing > 0) {
900
- reasons.push(reason + ' (waiting for current command)');
921
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
901
922
  }
902
923
  } else {
903
924
  // 'enqueue' - block unconditionally
@@ -915,7 +936,7 @@ export class SolveQueue {
915
936
  if (sessionPercent !== null) {
916
937
  const sessionRatio = sessionPercent / 100;
917
938
  if (sessionRatio >= QUEUE_CONFIG.thresholds.codex5Hour.value) {
918
- const reason = formatWaitingReason('codex_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.codex5Hour.value);
939
+ const reason = formatWaitingReason('codex_5_hour_session', sessionPercent, QUEUE_CONFIG.thresholds.codex5Hour.value, { locale });
919
940
  const strategy = QUEUE_CONFIG.thresholds.codex5Hour.strategy;
920
941
  this.recordThrottle(sessionRatio >= 1.0 ? 'codex_5_hour_session_100' : `codex_5_hour_session_${strategy}`);
921
942
 
@@ -925,7 +946,7 @@ export class SolveQueue {
925
946
  } else if (strategy === 'dequeue-one-at-a-time') {
926
947
  oneAtATime = true;
927
948
  if (totalToolProcessing > 0) {
928
- reasons.push(reason + ' (waiting for current command)');
949
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
929
950
  }
930
951
  } else {
931
952
  reasons.push(reason);
@@ -936,7 +957,7 @@ export class SolveQueue {
936
957
  if (weeklyPercent !== null) {
937
958
  const weeklyRatio = weeklyPercent / 100;
938
959
  if (weeklyRatio >= QUEUE_CONFIG.thresholds.codexWeekly.value) {
939
- const reason = formatWaitingReason('codex_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.codexWeekly.value);
960
+ const reason = formatWaitingReason('codex_weekly', weeklyPercent, QUEUE_CONFIG.thresholds.codexWeekly.value, { locale });
940
961
  const strategy = QUEUE_CONFIG.thresholds.codexWeekly.strategy;
941
962
  this.recordThrottle(weeklyRatio >= 1.0 ? 'codex_weekly_100' : `codex_weekly_${strategy}`);
942
963
 
@@ -946,7 +967,7 @@ export class SolveQueue {
946
967
  } else if (strategy === 'dequeue-one-at-a-time') {
947
968
  oneAtATime = true;
948
969
  if (totalToolProcessing > 0) {
949
- reasons.push(reason + ' (waiting for current command)');
970
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
950
971
  }
951
972
  } else {
952
973
  reasons.push(reason);
@@ -967,7 +988,7 @@ export class SolveQueue {
967
988
  const usedPercent = githubResult.githubRateLimit.usedPercentage;
968
989
  const usedRatio = usedPercent / 100;
969
990
  if (usedRatio >= QUEUE_CONFIG.thresholds.githubApi.value) {
970
- const reason = formatWaitingReason('github', usedPercent, QUEUE_CONFIG.thresholds.githubApi.value);
991
+ const reason = formatWaitingReason('github', usedPercent, QUEUE_CONFIG.thresholds.githubApi.value, { locale });
971
992
  const strategy = QUEUE_CONFIG.thresholds.githubApi.strategy;
972
993
  this.recordThrottle(usedRatio >= 1.0 ? 'github_100' : `github_${strategy}`);
973
994
 
@@ -977,7 +998,7 @@ export class SolveQueue {
977
998
  } else if (strategy === 'dequeue-one-at-a-time') {
978
999
  oneAtATime = true;
979
1000
  if (totalToolProcessing > 0) {
980
- reasons.push(reason + ' (waiting for current command)');
1001
+ reasons.push(appendWaitingForCurrentCommand(reason, locale));
981
1002
  }
982
1003
  } else {
983
1004
  // 'enqueue' - block unconditionally
@@ -1125,7 +1146,7 @@ export class SolveQueue {
1125
1146
  // First check if the tool's threshold triggers a 'reject' strategy.
1126
1147
  // If so, reject all items at once rather than iterating one by one.
1127
1148
  // See: https://github.com/link-assistant/hive-mind/issues/1555
1128
- const toolCheck = await this.canStartCommand({ tool });
1149
+ const toolCheck = await this.canStartCommand({ tool, locale: toolQueue[0]?.locale || null });
1129
1150
  if (toolCheck.rejected) {
1130
1151
  await this.rejectAllItemsInQueue(tool, toolQueue, toolCheck.rejectReason);
1131
1152
  continue;
@@ -1134,9 +1155,10 @@ export class SolveQueue {
1134
1155
  for (let i = 0; i < toolQueue.length; i++) {
1135
1156
  const item = toolQueue[i];
1136
1157
  if (item.status === QueueItemStatus.QUEUED || item.status === QueueItemStatus.WAITING) {
1158
+ const itemCheck = item.locale === (toolQueue[0]?.locale || null) ? toolCheck : await this.canStartCommand({ tool, locale: item.locale });
1137
1159
  const previousStatus = item.status;
1138
1160
  const previousReason = item.waitingReason;
1139
- const waitReason = toolCheck.reason || 'Waiting in queue';
1161
+ const waitReason = itemCheck.reason || lt('queue_waiting_in_queue', {}, { locale: item.locale });
1140
1162
  item.setWaiting(waitReason);
1141
1163
 
1142
1164
  // Update message if status/reason changed or it's time for periodic update
@@ -1283,14 +1305,15 @@ export class SolveQueue {
1283
1305
  * @see https://github.com/link-assistant/hive-mind/issues/1159
1284
1306
  * @see https://github.com/link-assistant/hive-mind/issues/1267
1285
1307
  */
1286
- async formatStatus() {
1308
+ async formatStatus(options = {}) {
1309
+ const locale = getLocale(options);
1287
1310
  // Always show per-tool breakdown for all known queues
1288
1311
  const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1289
- let message = 'Queues\n';
1312
+ let message = `${lt('queues', {}, { locale })}\n`;
1290
1313
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1291
1314
  const pending = toolQueue.length;
1292
1315
  const processing = externalProcessing.byTool[tool] || 0;
1293
- message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
1316
+ message += `${tool} (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
1294
1317
  }
1295
1318
 
1296
1319
  return message;
@@ -1320,39 +1343,40 @@ export class SolveQueue {
1320
1343
  * @see https://github.com/link-assistant/hive-mind/issues/1159
1321
1344
  * @see https://github.com/link-assistant/hive-mind/issues/1267
1322
1345
  */
1323
- async formatDetailedStatus() {
1346
+ async formatDetailedStatus(options = {}) {
1347
+ const locale = getLocale(options);
1324
1348
  const stats = this.getStats();
1325
1349
  const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1326
1350
 
1327
1351
  // Get actual processing counts for each tool queue.
1328
1352
  // This combines pgrep with tracked isolation status so users see detached
1329
1353
  // screen-isolated work even when the direct AI CLI process count is lower.
1330
- let message = '📋 *Solve Queue Status*\n\n';
1354
+ let message = `📋 *${lt('solve_queue_status', {}, { locale })}*\n\n`;
1331
1355
 
1332
1356
  // Show per-tool queue breakdown with items grouped by queue
1333
1357
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1334
1358
  const pending = toolQueue.length;
1335
1359
  const processing = externalProcessing.byTool[tool] || 0;
1336
- message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
1360
+ message += `*${tool}* (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
1337
1361
 
1338
1362
  // Show first 5 queued items for this tool
1339
1363
  const displayItems = toolQueue.slice(0, 5);
1340
1364
  for (const item of displayItems) {
1341
- const waitTime = formatDuration(item.getWaitTime());
1342
- message += ` • ${item.url} (${item.status}, ${waitTime})\n`;
1365
+ const waitTime = formatDuration(item.getWaitTime(), { locale });
1366
+ message += ` • ${item.url} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1343
1367
  if (item.waitingReason) {
1344
1368
  message += ` └ ${item.waitingReason}\n`;
1345
1369
  }
1346
1370
  }
1347
1371
  if (toolQueue.length > 5) {
1348
- message += ` ... and ${toolQueue.length - 5} more\n`;
1372
+ message += ` ... ${lt('queue_and_more', { count: toolQueue.length - 5 }, { locale })}\n`;
1349
1373
  }
1350
1374
 
1351
1375
  message += '\n';
1352
1376
  }
1353
1377
 
1354
1378
  // Summary stats
1355
- message += `Completed: ${stats.completed}, Failed: ${stats.failed}\n`;
1379
+ message += `${lt('queue_completed', {}, { locale })}: ${stats.completed}, ${lt('queue_failed', {}, { locale })}: ${stats.failed}\n`;
1356
1380
 
1357
1381
  return message;
1358
1382
  }