@link-assistant/hive-mind 1.42.0 → 1.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e7ce2dd: Add TELEGRAM_ALLOWED_TOPICS for forum topic filtering (issue #1100)
8
+
9
+ ## 1.43.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 91479e3: Better /version command output with uniform formatting and bug fixes: add regex version parsers for all 40+ tools, fix LLD/Xvfb/Playwright MCP detection, add Playwright browser cache fallback, fail Docker build on MCP registration failure
14
+
3
15
  ## 1.42.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/lino.lib.mjs CHANGED
@@ -96,6 +96,48 @@ export class LinksNotationManager {
96
96
  return [];
97
97
  }
98
98
 
99
+ parseLinks(input) {
100
+ if (!input) return [];
101
+
102
+ const parsed = this.parser.parse(input);
103
+ if (!parsed || parsed.length === 0) return [];
104
+
105
+ const link = parsed[0];
106
+ const pairs = [];
107
+
108
+ if (link.values && link.values.length > 0) {
109
+ const flatNumbers = [];
110
+
111
+ for (const value of link.values) {
112
+ if (value.id === null && value.values && value.values.length >= 2) {
113
+ const source = parseInt(value.values[0]?.id || value.values[0], 10);
114
+ const target = parseInt(value.values[1]?.id || value.values[1], 10);
115
+ if (!isNaN(source) && !isNaN(target)) {
116
+ pairs.push({ source, target });
117
+ }
118
+ } else if (value.id) {
119
+ const num = parseInt(value.id, 10);
120
+ if (!isNaN(num)) {
121
+ flatNumbers.push(num);
122
+ }
123
+ }
124
+ }
125
+
126
+ for (let i = 0; i < flatNumbers.length - 1; i += 2) {
127
+ pairs.push({ source: flatNumbers[i], target: flatNumbers[i + 1] });
128
+ }
129
+ }
130
+
131
+ return pairs;
132
+ }
133
+
134
+ formatLinks(pairs) {
135
+ if (!pairs || pairs.length === 0) return '()';
136
+
137
+ const formattedValues = pairs.map(pair => ` ${pair.source} ${pair.target}`).join('\n');
138
+ return `(\n${formattedValues}\n)`;
139
+ }
140
+
99
141
  format(values) {
100
142
  if (!values || values.length === 0) return '()';
101
143
 
@@ -116,10 +116,12 @@ function buildProgressMessage(state) {
116
116
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
117
117
  * @param {Function} options.isGroupChat - Function to check if chat is a group
118
118
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
119
+ * @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
120
+ * @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
119
121
  * @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
120
122
  */
121
123
  export function registerAcceptInvitesCommand(bot, options) {
122
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb } = options;
124
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb } = options;
123
125
 
124
126
  bot.command(/^accept[_-]?invites$/i, async ctx => {
125
127
  VERBOSE && console.log('[VERBOSE] /accept_invites command received');
@@ -134,11 +136,11 @@ export function registerAcceptInvitesCommand(bot, options) {
134
136
  return await ctx.reply('❌ The /accept_invites command only works in group chats. Please add this bot to a group and make it an admin.', {
135
137
  reply_to_message_id: ctx.message.message_id,
136
138
  });
137
- const chatId = ctx.chat.id;
138
- if (!isChatAuthorized(chatId))
139
- return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
140
- reply_to_message_id: ctx.message.message_id,
141
- });
139
+ const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
140
+ if (!authorize(ctx)) {
141
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
142
+ return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
143
+ }
142
144
 
143
145
  const fetchingMessage = await ctx.reply('🔄 Fetching pending GitHub invitations\\.\\.\\.', {
144
146
  reply_to_message_id: ctx.message.message_id,
@@ -78,6 +78,12 @@ const config = yargs(hideBin(process.argv))
78
78
  alias: 'allowed-chats',
79
79
  default: getenv('TELEGRAM_ALLOWED_CHATS', ''),
80
80
  })
81
+ .option('allowedTopics', {
82
+ type: 'string',
83
+ description: 'Allowed topic IDs in Links Notation format "chatId topicId" pairs',
84
+ alias: 'allowed-topics',
85
+ default: getenv('TELEGRAM_ALLOWED_TOPICS', ''),
86
+ })
81
87
  .option('solveOverrides', {
82
88
  type: 'string',
83
89
  description: 'Override options for /solve command in lino notation, e.g., "(\n --auto-continue\n --attach-logs\n)"',
@@ -154,6 +160,10 @@ if (!BOT_TOKEN) {
154
160
  const resolvedAllowedChats = config.allowedChats || getenv('TELEGRAM_ALLOWED_CHATS', '');
155
161
  const allowedChats = resolvedAllowedChats ? lino.parseNumericIds(resolvedAllowedChats) : null;
156
162
 
163
+ // Parse allowed topics (chatId:topicId pairs in Links Notation)
164
+ const resolvedAllowedTopics = config.allowedTopics || getenv('TELEGRAM_ALLOWED_TOPICS', '');
165
+ const allowedTopics = resolvedAllowedTopics ? lino.parseLinks(resolvedAllowedTopics) : null;
166
+
157
167
  // Parse override options
158
168
  const resolvedSolveOverrides = config.solveOverrides || getenv('TELEGRAM_SOLVE_OVERRIDES', '');
159
169
  const solveOverrides = resolvedSolveOverrides
@@ -277,6 +287,9 @@ if (config.dryRun) {
277
287
  } else {
278
288
  console.log(' Allowed chats: All (no restrictions)');
279
289
  }
290
+ if (allowedTopics && allowedTopics.length > 0) {
291
+ console.log(' Allowed topics:', lino.formatLinks(allowedTopics));
292
+ }
280
293
  console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled });
281
294
  if (solveOverrides.length > 0) {
282
295
  console.log(' Solve overrides:', lino.format(solveOverrides));
@@ -319,6 +332,22 @@ function isChatAuthorized(chatId) {
319
332
  return _isChatAuthorized(chatId, allowedChats);
320
333
  }
321
334
 
335
+ // Topic-level authorization (issue #1100): chat-level auth overrides topic-level
336
+ function isTopicAuthorized(ctx) {
337
+ if (isChatAuthorized(ctx.chat?.id)) return true;
338
+ if (!allowedTopics || allowedTopics.length === 0) return false;
339
+ const chatId = ctx.chat?.id;
340
+ const topicId = ctx.message?.message_thread_id;
341
+ return allowedTopics.some(pair => pair.source === chatId && pair.target === topicId);
342
+ }
343
+ function buildAuthErrorMessage(ctx) {
344
+ const chatId = ctx.chat?.id;
345
+ const topicId = ctx.message?.message_thread_id;
346
+ let msg = `❌ This chat (ID: ${chatId})`;
347
+ if (topicId) msg += ` and topic (ID: ${topicId})`;
348
+ return msg + ' is not authorized.\n\nUse /help to see your chat and topic IDs.';
349
+ }
350
+
322
351
  function isOldMessage(ctx) {
323
352
  return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
324
353
  }
@@ -621,7 +650,7 @@ bot.command('help', async ctx => {
621
650
  const chatId = ctx.chat.id;
622
651
  const chatType = ctx.chat.type;
623
652
  const chatTitle = ctx.chat.title || 'Private Chat';
624
-
653
+ const topicId = ctx.message?.message_thread_id; // Forum topic ID (issue #1100)
625
654
  let message = '🤖 *SwarmMindBot Help*\n\n';
626
655
 
627
656
  // Show stopped status if chat is stopped (issue #1081)
@@ -638,6 +667,7 @@ bot.command('help', async ctx => {
638
667
 
639
668
  message += '📋 *Diagnostic Information:*\n';
640
669
  message += `• Chat ID: \`${chatId}\`\n`;
670
+ if (topicId) message += `• Topic ID: \`${topicId}\`\n`;
641
671
  message += `• Chat Type: ${chatType}\n`;
642
672
  message += `• Chat Title: ${chatTitle}\n\n`;
643
673
  message += '📝 *Available Commands:*\n\n';
@@ -685,9 +715,10 @@ bot.command('help', async ctx => {
685
715
  message += '• `--verbose` or `-v` - Verbose output | `--attach-logs` - Attach logs to PR\n';
686
716
  message += '\n💡 *Tip:* Many more options available. See full documentation for complete list.\n';
687
717
 
688
- if (allowedChats) {
689
- message += '\n🔒 *Restricted Mode:* This bot only accepts commands from authorized chats.\n';
690
- message += `Authorized: ${isChatAuthorized(chatId) ? '✅ Yes' : '❌ No'}`;
718
+ if (allowedChats || allowedTopics) {
719
+ const authorized = isTopicAuthorized(ctx);
720
+ message += `\n🔒 *Restricted Mode:* Authorized: ${authorized ? '✅ Yes' : '❌ No'}`;
721
+ if (!authorized && topicId) message += `\n💡 To allow this topic: \`TELEGRAM_ALLOWED_TOPICS="(${chatId} ${topicId})"\``;
691
722
  }
692
723
 
693
724
  message += '\n\n🔧 *Troubleshooting:*\n';
@@ -728,10 +759,11 @@ bot.command('limits', async ctx => {
728
759
  return;
729
760
  }
730
761
 
731
- const chatId = ctx.chat.id;
732
- if (!isChatAuthorized(chatId)) {
733
- VERBOSE && console.log('[VERBOSE] /limits ignored: chat not authorized');
734
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
762
+ if (!isTopicAuthorized(ctx)) {
763
+ if (VERBOSE) {
764
+ console.log('[VERBOSE] /limits ignored: not authorized');
765
+ }
766
+ await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
735
767
  return;
736
768
  }
737
769
 
@@ -768,8 +800,7 @@ bot.command('version', async ctx => {
768
800
  });
769
801
  if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
770
802
  if (!_isGroupChat(ctx)) return await ctx.reply('❌ The /version 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 });
771
- const chatId = ctx.chat.id;
772
- if (!isChatAuthorized(chatId)) return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
803
+ if (!isTopicAuthorized(ctx)) return await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
773
804
  const fetchingMessage = await ctx.reply('🔄 Gathering version information...', {
774
805
  reply_to_message_id: ctx.message.message_id,
775
806
  });
@@ -778,48 +809,18 @@ bot.command('version', async ctx => {
778
809
  await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, '🤖 *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
779
810
  });
780
811
 
781
- // Register /accept_invites command from separate module
782
- // This keeps telegram-bot.mjs under the 1500 line limit
812
+ // Register external command modules (keeps telegram-bot.mjs under line limit)
783
813
  const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
784
- registerAcceptInvitesCommand(bot, {
785
- VERBOSE,
786
- isOldMessage,
787
- isForwardedOrReply,
788
- isGroupChat: _isGroupChat,
789
- isChatAuthorized,
790
- addBreadcrumb,
791
- });
792
-
793
- // Register /merge command from separate module (experimental, see issue #1143)
814
+ const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
815
+ registerAcceptInvitesCommand(bot, sharedCommandOpts);
794
816
  const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
795
- registerMergeCommand(bot, {
796
- VERBOSE,
797
- isOldMessage,
798
- isForwardedOrReply,
799
- isGroupChat: _isGroupChat,
800
- isChatAuthorized,
801
- addBreadcrumb,
802
- isChatStopped,
803
- getStoppedChatRejectMessage,
804
- });
805
-
806
- // Register /solve_queue command from separate module (issue #1232)
817
+ registerMergeCommand(bot, sharedCommandOpts);
807
818
  const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
808
- const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, {
809
- VERBOSE,
810
- isOldMessage,
811
- isForwardedOrReply,
812
- isGroupChat: _isGroupChat,
813
- isChatAuthorized,
814
- addBreadcrumb,
815
- getSolveQueue,
816
- });
819
+ const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
817
820
 
818
821
  // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
819
822
  async function handleSolveCommand(ctx) {
820
- if (VERBOSE) {
821
- console.log('[VERBOSE] /solve command received');
822
- }
823
+ VERBOSE && console.log('[VERBOSE] /solve command received');
823
824
 
824
825
  // Add breadcrumb for error tracking
825
826
  await addBreadcrumb({
@@ -871,16 +872,16 @@ async function handleSolveCommand(ctx) {
871
872
  return;
872
873
  }
873
874
 
874
- const chatId = ctx.chat.id;
875
- if (!isChatAuthorized(chatId)) {
875
+ if (!isTopicAuthorized(ctx)) {
876
876
  if (VERBOSE) {
877
- console.log('[VERBOSE] /solve ignored: chat not authorized');
877
+ console.log('[VERBOSE] /solve ignored: not authorized');
878
878
  }
879
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
879
+ await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
880
880
  return;
881
881
  }
882
882
 
883
883
  // Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
884
+ const chatId = ctx.chat.id;
884
885
  if (isChatStopped(chatId)) {
885
886
  VERBOSE && console.log('[VERBOSE] /solve rejected: chat is stopped');
886
887
  await safeReply(ctx, getStoppedChatRejectMessage(chatId, 'Solve'), { reply_to_message_id: ctx.message.message_id });
@@ -1017,7 +1018,7 @@ async function handleSolveCommand(ctx) {
1017
1018
  const existingItem = solveQueue.findByUrl(normalizedUrl);
1018
1019
  if (existingItem) {
1019
1020
  const statusText = existingItem.status === 'starting' || existingItem.status === 'started' ? 'being processed' : 'already in the queue';
1020
- await safeReply(ctx, `❌ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\n💡 Use /solve\\_queue to check the queue status.`, { reply_to_message_id: ctx.message.message_id });
1021
+ await safeReply(ctx, `❌ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\n💡 Use /solve_queue to check the queue status.`, { reply_to_message_id: ctx.message.message_id });
1021
1022
  return;
1022
1023
  }
1023
1024
 
@@ -1099,16 +1100,16 @@ async function handleHiveCommand(ctx) {
1099
1100
  return;
1100
1101
  }
1101
1102
 
1102
- const chatId = ctx.chat.id;
1103
- if (!isChatAuthorized(chatId)) {
1103
+ if (!isTopicAuthorized(ctx)) {
1104
1104
  if (VERBOSE) {
1105
- console.log('[VERBOSE] /hive ignored: chat not authorized');
1105
+ console.log('[VERBOSE] /hive ignored: not authorized');
1106
1106
  }
1107
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
1107
+ await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
1108
1108
  return;
1109
1109
  }
1110
1110
 
1111
1111
  // Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
1112
+ const chatId = ctx.chat.id;
1112
1113
  if (isChatStopped(chatId)) {
1113
1114
  VERBOSE && console.log('[VERBOSE] /hive rejected: chat is stopped');
1114
1115
  await safeReply(ctx, getStoppedChatRejectMessage(chatId, 'Hive'), { reply_to_message_id: ctx.message.message_id });
@@ -1201,12 +1202,10 @@ async function handleHiveCommand(ctx) {
1201
1202
 
1202
1203
  bot.command(/^hive$/i, handleHiveCommand);
1203
1204
 
1204
- // Register commands from separate modules (keeps telegram-bot.mjs under line limit)
1205
1205
  const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1206
1206
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1207
- const commandOptions = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized };
1208
- registerTopCommand(bot, commandOptions);
1209
- registerStartStopCommands(bot, commandOptions); // issue #1081
1207
+ registerTopCommand(bot, sharedCommandOpts);
1208
+ registerStartStopCommands(bot, sharedCommandOpts);
1210
1209
 
1211
1210
  // Add message listener for verbose debugging
1212
1211
  if (VERBOSE) {
@@ -1389,6 +1388,9 @@ if (allowedChats && allowedChats.length > 0) {
1389
1388
  } else {
1390
1389
  console.log('Allowed chats: All (no restrictions)');
1391
1390
  }
1391
+ if (allowedTopics && allowedTopics.length > 0) {
1392
+ console.log('Allowed topics (lino):', lino.formatLinks(allowedTopics));
1393
+ }
1392
1394
  console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled });
1393
1395
  if (solveOverrides.length > 0) console.log('Solve overrides (lino):', lino.format(solveOverrides));
1394
1396
  if (hiveOverrides.length > 0) console.log('Hive overrides (lino):', lino.format(hiveOverrides));
@@ -130,10 +130,14 @@ function formatUserError(error, verbose) {
130
130
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
131
131
  * @param {Function} options.isGroupChat - Function to check if chat is a group
132
132
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
133
+ * @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
134
+ * @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
133
135
  * @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
136
+ * @param {Function} [options.isChatStopped] - Function to check if chat is stopped (issue #1081)
137
+ * @param {Function} [options.getStoppedChatRejectMessage] - Function to get stopped chat rejection message
134
138
  */
135
139
  export function registerMergeCommand(bot, options) {
136
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
140
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
137
141
 
138
142
  bot.command(/^merge$/i, async ctx => {
139
143
  VERBOSE && console.log('[VERBOSE] /merge command received');
@@ -154,13 +158,14 @@ export function registerMergeCommand(bot, options) {
154
158
  });
155
159
  }
156
160
 
157
- const chatId = ctx.chat.id;
158
- if (!isChatAuthorized(chatId)) {
159
- return await ctx.reply(`This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
160
- reply_to_message_id: ctx.message.message_id,
161
- });
161
+ const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
162
+ if (!authorize(ctx)) {
163
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `This chat (ID: ${ctx.chat.id}) is not authorized.`;
164
+ return await ctx.reply(errMsg, { reply_to_message_id: ctx.message.message_id });
162
165
  }
163
166
 
167
+ const chatId = ctx.chat.id;
168
+
164
169
  // Check if chat is stopped (issue #1081) - reject with same style as queue rejected mode
165
170
  if (isChatStopped && isChatStopped(chatId)) {
166
171
  VERBOSE && console.log('[VERBOSE] /merge rejected: chat is stopped');
@@ -22,12 +22,14 @@
22
22
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
23
23
  * @param {Function} options.isGroupChat - Function to check if chat is a group
24
24
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
25
+ * @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
26
+ * @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
25
27
  * @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
26
28
  * @param {Function} options.getSolveQueue - Function to get the solve queue instance
27
29
  * @returns {{ handleSolveQueueCommand: Function }} The command handler for use in text fallback
28
30
  */
29
31
  export function registerSolveQueueCommand(bot, options) {
30
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, getSolveQueue } = options;
32
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, getSolveQueue } = options;
31
33
 
32
34
  async function handleSolveQueueCommand(ctx) {
33
35
  VERBOSE && console.log('[VERBOSE] /solve_queue command received');
@@ -59,12 +61,11 @@ export function registerSolveQueueCommand(bot, options) {
59
61
  return;
60
62
  }
61
63
 
62
- const chatId = ctx.chat.id;
63
- if (!isChatAuthorized(chatId)) {
64
- VERBOSE && console.log('[VERBOSE] /solve_queue ignored: chat not authorized');
65
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
66
- reply_to_message_id: ctx.message.message_id,
67
- });
64
+ const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
65
+ if (!authorize(ctx)) {
66
+ VERBOSE && console.log('[VERBOSE] /solve_queue ignored: not authorized');
67
+ 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 });
68
69
  return;
69
70
  }
70
71
 
@@ -51,9 +51,11 @@ async function captureTopOutput(chatId) {
51
51
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
52
52
  * @param {Function} options.isGroupChat - Function to check if chat is a group
53
53
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
54
+ * @param {Function} [options.isTopicAuthorized] - Function to check if topic is authorized (issue #1100)
55
+ * @param {Function} [options.buildAuthErrorMessage] - Function to build authorization error message
54
56
  */
55
57
  export function registerTopCommand(bot, options) {
56
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
58
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
57
59
 
58
60
  // /top command - show system top output in an auto-updating message (EXPERIMENTAL)
59
61
  // Only accessible by chat owner
@@ -89,17 +91,20 @@ export function registerTopCommand(bot, options) {
89
91
  return;
90
92
  }
91
93
 
92
- const chatId = ctx.chat.id;
93
- if (!isChatAuthorized(chatId)) {
94
+ const authorize = isTopicAuthorized || (ctx => isChatAuthorized(ctx.chat.id));
95
+ if (!authorize(ctx)) {
94
96
  if (VERBOSE) {
95
- console.log('[VERBOSE] /top ignored: chat not authorized');
97
+ console.log('[VERBOSE] /top ignored: not authorized');
96
98
  }
97
- await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot.`, {
99
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`;
100
+ await ctx.reply(errMsg, {
98
101
  reply_to_message_id: ctx.message.message_id,
99
102
  });
100
103
  return;
101
104
  }
102
105
 
106
+ const chatId = ctx.chat.id;
107
+
103
108
  // Check if user is chat owner
104
109
  try {
105
110
  const chatMember = await ctx.telegram.getChatMember(chatId, ctx.from.id);
@@ -33,6 +33,397 @@ async function execCommandAsync(command, timeout = 5000) {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Per-tool regex parsers to normalize raw --version output into uniform format:
38
+ * <version> (<commit>, <revision>, <date>, etc.)
39
+ *
40
+ * Each parser returns { version, extra[] } or null if it doesn't match.
41
+ * The `version` is the most specific version string (for bug reporting).
42
+ * Items in `extra` are joined with ", " and placed in parentheses.
43
+ *
44
+ * @type {Record<string, (raw: string) => {version: string, extra: string[]} | null>}
45
+ */
46
+ const VERSION_PARSERS = {
47
+ // rustc 1.94.1 (e408947bf 2026-03-25)
48
+ rust: raw => {
49
+ const m = raw.match(/^rustc\s+([\d.]+(?:-\S+)?)\s*(?:\(([^)]+)\))?/);
50
+ if (!m) return null;
51
+ const extra = m[2] ? m[2].trim().split(/\s+/) : [];
52
+ return { version: m[1], extra };
53
+ },
54
+ // cargo 1.94.1 (29ea6fb6a 2026-03-24)
55
+ cargo: raw => {
56
+ const m = raw.match(/^cargo\s+([\d.]+(?:-\S+)?)\s*(?:\(([^)]+)\))?/);
57
+ if (!m) return null;
58
+ const extra = m[2] ? m[2].trim().split(/\s+/) : [];
59
+ return { version: m[1], extra };
60
+ },
61
+ // go version go1.26.1 linux/amd64
62
+ go: raw => {
63
+ const m = raw.match(/go([\d.]+(?:\S*)?)\s+(.*)/);
64
+ if (!m) return null;
65
+ return { version: m[1], extra: [m[2].trim()] };
66
+ },
67
+ // PHP 8.3.30 (cli) (built: Jan 13 2026 22:36:55) (NTS)
68
+ php: raw => {
69
+ const m = raw.match(/^PHP\s+([\d.]+(?:-\S+)?)\s*(.*)/);
70
+ if (!m) return null;
71
+ const tags = [];
72
+ const parts = m[2].matchAll(/\(([^)]+)\)/g);
73
+ for (const p of parts) tags.push(p[1]);
74
+ return { version: m[1], extra: tags };
75
+ },
76
+ // openjdk version "21" 2023-09-19 LTS
77
+ java: raw => {
78
+ const m = raw.match(/version\s+"([^"]+)"(?:\s+(.+))?/);
79
+ if (!m) return null;
80
+ return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
81
+ },
82
+ // gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 — use full distro version
83
+ gcc: raw => {
84
+ const m = raw.match(/^gcc\s+(?:\((\S+)\s+([\d.]+\S*)\)\s+)?([\d.]+)/);
85
+ if (!m) return null;
86
+ // If distro info present, use full distro version (e.g. 13.3.0-6ubuntu2~24.04.1)
87
+ if (m[1] && m[2]) return { version: m[2], extra: [] };
88
+ return { version: m[3], extra: [] };
89
+ },
90
+ // g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 — use full distro version
91
+ gpp: raw => {
92
+ const m = raw.match(/^g\+\+\s+(?:\((\S+)\s+([\d.]+\S*)\)\s+)?([\d.]+)/);
93
+ if (!m) return null;
94
+ // If distro info present, use full distro version (e.g. 13.3.0-6ubuntu2~24.04.1)
95
+ if (m[1] && m[2]) return { version: m[2], extra: [] };
96
+ return { version: m[3], extra: [] };
97
+ },
98
+ // clang version 17.0.0 (https://github.com/... commit)
99
+ clang: raw => {
100
+ const m = raw.match(/^clang\s+version\s+([\d.]+(?:-\S+)?)\s*(?:\(([^)]+)\))?/);
101
+ if (!m) return null;
102
+ return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
103
+ },
104
+ // LLD 17.0.0 (compatible with GNU linkers) — only version number matters
105
+ lld: raw => {
106
+ const m = raw.match(/^LLD\s+([\d.]+)/);
107
+ if (!m) return null;
108
+ return { version: m[1], extra: [] };
109
+ },
110
+ // Python 3.14.3
111
+ python: raw => {
112
+ const m = raw.match(/^Python\s+([\d.]+(?:\S*)?)/);
113
+ if (!m) return null;
114
+ return { version: m[1], extra: [] };
115
+ },
116
+ // ruby 3.4.9 (2026-03-11 revision 76cca827ab) +PRISM [x86_64-linux]
117
+ ruby: raw => {
118
+ const m = raw.match(/^ruby\s+([\d.]+(?:p\d+)?)\s*(?:\(([^)]+)\))?\s*(.*)/);
119
+ if (!m) return null;
120
+ const extra = [];
121
+ if (m[2]) extra.push(m[2].trim());
122
+ const tail = m[3] ? m[3].trim() : '';
123
+ if (tail) extra.push(tail);
124
+ return { version: m[1], extra };
125
+ },
126
+ // Kotlin version 2.3.20-release-208 (JRE 21+35-LTS)
127
+ kotlin: raw => {
128
+ const m = raw.match(/^Kotlin\s+version\s+([\d.\-\w]+)\s*(?:\(([^)]+)\))?/);
129
+ if (!m) return null;
130
+ return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
131
+ },
132
+ // Swift version 6.0.3 (swift-6.0.3-RELEASE)
133
+ swift: raw => {
134
+ const m = raw.match(/^Swift\s+version\s+([\d.]+(?:\.\d+)?)\s*(?:\(([^)]+)\))?/);
135
+ if (!m) return null;
136
+ return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
137
+ },
138
+ // R version 4.3.3 (2024-02-29) -- "Angel Food Cake"
139
+ r: raw => {
140
+ const m = raw.match(/^R\s+version\s+([\d.]+)\s*(?:\(([^)]+)\))?(?:\s+--\s+"([^"]+)")?/);
141
+ if (!m) return null;
142
+ const extra = [];
143
+ if (m[2]) extra.push(m[2]);
144
+ if (m[3]) extra.push(m[3]);
145
+ return { version: m[1], extra };
146
+ },
147
+ // git version 2.43.0
148
+ git: raw => {
149
+ const m = raw.match(/^git\s+version\s+([\d.]+)/);
150
+ if (!m) return null;
151
+ return { version: m[1], extra: [] };
152
+ },
153
+ // gh version 2.89.0 (2026-03-26)
154
+ gh: raw => {
155
+ const m = raw.match(/^gh\s+version\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
156
+ if (!m) return null;
157
+ return { version: m[1], extra: m[2] ? [m[2]] : [] };
158
+ },
159
+ // glab version 1.36.0
160
+ glab: raw => {
161
+ const m = raw.match(/^glab\s+version\s+([\d.]+)/);
162
+ if (!m) return null;
163
+ return { version: m[1], extra: [] };
164
+ },
165
+ // curl 8.19.0 (x86_64-pc-linux-gnu) libcurl/8.19.0 ...
166
+ curl: raw => {
167
+ const m = raw.match(/^curl\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
168
+ if (!m) return null;
169
+ return { version: m[1], extra: m[2] ? [m[2]] : [] };
170
+ },
171
+ // GNU Wget 1.21.4 built on linux-gnu.
172
+ wget: raw => {
173
+ const m = raw.match(/^GNU\s+Wget\s+([\d.]+)/);
174
+ if (!m) return null;
175
+ return { version: m[1], extra: [] };
176
+ },
177
+ // cmake version 3.28.3
178
+ cmake: raw => {
179
+ const m = raw.match(/^cmake\s+version\s+([\d.]+)/);
180
+ if (!m) return null;
181
+ return { version: m[1], extra: [] };
182
+ },
183
+ // GNU Make 4.3
184
+ make: raw => {
185
+ const m = raw.match(/^GNU\s+Make\s+([\d.]+)/);
186
+ if (!m) return null;
187
+ return { version: m[1], extra: [] };
188
+ },
189
+ // NASM version 2.16.01
190
+ nasm: raw => {
191
+ const m = raw.match(/^NASM\s+version\s+([\d.]+)/);
192
+ if (!m) return null;
193
+ return { version: m[1], extra: [] };
194
+ },
195
+ // flat assembler version 1.73.32
196
+ fasm: raw => {
197
+ const m = raw.match(/version\s+([\d.]+)/);
198
+ if (!m) return null;
199
+ return { version: m[1], extra: [] };
200
+ },
201
+ // Screen version 4.09.01 (GNU) 20-Aug-23
202
+ screen: raw => {
203
+ const m = raw.match(/^Screen\s+version\s+([\d.]+)\s*(?:\(([^)]+)\))?\s*(.*)/);
204
+ if (!m) return null;
205
+ const extra = [];
206
+ if (m[2]) extra.push(m[2]);
207
+ if (m[3] && m[3].trim()) extra.push(m[3].trim());
208
+ return { version: m[1], extra };
209
+ },
210
+ // expect version 5.45.4
211
+ expect: raw => {
212
+ const m = raw.match(/^expect\s+version\s+([\d.]+)/);
213
+ if (!m) return null;
214
+ return { version: m[1], extra: [] };
215
+ },
216
+ // The OCaml toplevel, version 5.4.1
217
+ ocaml: raw => {
218
+ const m = raw.match(/version\s+([\d.]+)/);
219
+ if (!m) return null;
220
+ return { version: m[1], extra: [] };
221
+ },
222
+ // The Rocq Prover, version 9.1.1
223
+ rocq: raw => {
224
+ const m = raw.match(/version\s+([\d.]+)/);
225
+ if (!m) return null;
226
+ return { version: m[1], extra: [] };
227
+ },
228
+ // elan 4.2.1 (3d5138e15 2026-03-18)
229
+ elan: raw => {
230
+ const m = raw.match(/^elan\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
231
+ if (!m) return null;
232
+ const extra = m[2] ? m[2].trim().split(/\s+/) : [];
233
+ return { version: m[1], extra };
234
+ },
235
+ // Lean (version 4.29.0, x86_64-unknown-linux-gnu, commit abc123, Release)
236
+ lean: raw => {
237
+ const m = raw.match(/version\s+([\d.]+)(?:,\s*(.+?))\)?$/);
238
+ if (!m) return null;
239
+ const extra = m[2]
240
+ ? m[2]
241
+ .split(',')
242
+ .map(s => s.trim().replace(/\)$/, ''))
243
+ .filter(Boolean)
244
+ : [];
245
+ return { version: m[1], extra };
246
+ },
247
+ // Google Chrome 146.0.7680.164
248
+ chrome: raw => {
249
+ const m = raw.match(/^Google\s+Chrome\s+([\d.]+)/);
250
+ if (!m) return null;
251
+ return { version: m[1], extra: [] };
252
+ },
253
+ // Chromium 137.0.7151.0
254
+ chromium: raw => {
255
+ const m = raw.match(/^Chromium\s+([\d.]+)/);
256
+ if (!m) return null;
257
+ return { version: m[1], extra: [] };
258
+ },
259
+ // Mozilla Firefox 139.0
260
+ firefox: raw => {
261
+ const m = raw.match(/^Mozilla\s+Firefox\s+([\d.]+)/);
262
+ if (!m) return null;
263
+ return { version: m[1], extra: [] };
264
+ },
265
+ // Microsoft Edge 146.0.3856.84
266
+ msedge: raw => {
267
+ const m = raw.match(/^Microsoft\s+Edge\s+([\d.]+)/);
268
+ if (!m) return null;
269
+ return { version: m[1], extra: [] };
270
+ },
271
+ // deno 2.7.9 (stable, release, x86_64-unknown-linux-gnu)
272
+ deno: raw => {
273
+ const m = raw.match(/^deno\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
274
+ if (!m) return null;
275
+ const extra = m[2]
276
+ ? m[2]
277
+ .split(',')
278
+ .map(s => s.trim())
279
+ .filter(Boolean)
280
+ : [];
281
+ return { version: m[1], extra };
282
+ },
283
+ // Version 1.58.2 (Playwright CLI)
284
+ playwright: raw => {
285
+ const m = raw.match(/(?:Version\s+)?([\d.]+)/);
286
+ if (!m) return null;
287
+ return { version: m[1], extra: [] };
288
+ },
289
+ // @playwright/test@1.58.2
290
+ playwrightTest: raw => {
291
+ const m = raw.match(/@playwright\/test@([\d.]+)/);
292
+ if (!m) return null;
293
+ return { version: m[1], extra: [] };
294
+ },
295
+ // @playwright/mcp@0.0.69 or `-- @playwright/mcp@0.0.69
296
+ playwrightMcp: raw => {
297
+ const m = raw.match(/@playwright\/mcp@([\d.]+)/);
298
+ if (!m) return null;
299
+ return { version: m[1], extra: [] };
300
+ },
301
+ // @puppeteer/browsers@2.13.0
302
+ puppeteerBrowsers: raw => {
303
+ const m = raw.match(/@puppeteer\/browsers@([\d.]+)/);
304
+ if (!m) return null;
305
+ return { version: m[1], extra: [] };
306
+ },
307
+ // 2.1.87 (Claude Code)
308
+ claudeCode: raw => {
309
+ const m = raw.match(/([\d.]+)\s*(?:\(([^)]+)\))?/);
310
+ if (!m) return null;
311
+ return { version: m[1], extra: m[2] ? [m[2]] : [] };
312
+ },
313
+ // GitHub Copilot CLI 1.0.14.\nRun 'copilot update'...
314
+ copilot: raw => {
315
+ const m = raw.match(/([\d.]+)/);
316
+ if (!m) return null;
317
+ // Strip trailing dot from version (e.g. "1.0.14." -> "1.0.14")
318
+ const version = m[1].replace(/\.$/, '');
319
+ return { version, extra: [] };
320
+ },
321
+ // pyenv 2.6.26
322
+ pyenv: raw => {
323
+ const m = raw.match(/^pyenv\s+([\d.]+)/);
324
+ if (!m) return null;
325
+ return { version: m[1], extra: [] };
326
+ },
327
+ // /workspace/.perl5/bin/perlbrew - App::perlbrew/1.02
328
+ perlbrew: raw => {
329
+ const m = raw.match(/App::perlbrew\/([\d.]+)/);
330
+ if (!m) return null;
331
+ return { version: m[1], extra: [] };
332
+ },
333
+ // rbenv 1.3.2-20-g23c3041
334
+ rbenv: raw => {
335
+ const m = raw.match(/^rbenv\s+([\d.]+(?:-[\w]+)*)/);
336
+ if (!m) return null;
337
+ return { version: m[1], extra: [] };
338
+ },
339
+ // Homebrew 5.1.2
340
+ brew: raw => {
341
+ const m = raw.match(/^Homebrew\s+([\d.]+)/);
342
+ if (!m) return null;
343
+ return { version: m[1], extra: [] };
344
+ },
345
+ // This is Zip 3.0 (July 5th 2008), by Info-ZIP.
346
+ zip: raw => {
347
+ const m = raw.match(/Zip\s+([\d.]+)\s*(?:\(([^)]+)\))?/);
348
+ if (!m) return null;
349
+ return { version: m[1], extra: m[2] ? [m[2]] : [] };
350
+ },
351
+ // UnZip 6.00 of 20 April 2009, by Debian.
352
+ unzip: raw => {
353
+ const m = raw.match(/UnZip\s+([\d.]+)\s*(?:of\s+([^,]+))?/);
354
+ if (!m) return null;
355
+ return { version: m[1], extra: m[2] ? [m[2].trim()] : [] };
356
+ },
357
+ // ii xvfb 2:21.1.12-1ubuntu1.5 amd64 Virtual Framebuffer...
358
+ xvfb: raw => {
359
+ // dpkg output format
360
+ const dpkg = raw.match(/^ii\s+xvfb\s+(\S+)/);
361
+ if (dpkg) {
362
+ // Strip epoch (e.g. "2:21.1.12-1ubuntu1.5" -> "21.1.12-1ubuntu1.5")
363
+ const ver = dpkg[1].replace(/^\d+:/, '');
364
+ return { version: ver, extra: [] };
365
+ }
366
+ // X.Org X Server version output (if it ever works)
367
+ const xorg = raw.match(/X\.Org\s+X\s+Server\s+([\d.]+)/);
368
+ if (xorg) return { version: xorg[1], extra: [] };
369
+ return null;
370
+ },
371
+ // Xvfb returns "Unrecognized option: -version" — this is handled by fixing the command
372
+ // to use dpkg fallback first
373
+
374
+ // agent 1.0.0 or similar
375
+ agent: raw => {
376
+ const m = raw.match(/([\d.]+)/);
377
+ if (!m) return null;
378
+ return { version: m[1], extra: [] };
379
+ },
380
+ // codex-cli 0.117.0 or similar
381
+ codex: raw => {
382
+ const m = raw.match(/([\d.]+)/);
383
+ if (!m) return null;
384
+ return { version: m[1], extra: [] };
385
+ },
386
+ // opencode 1.3.10 or similar
387
+ opencode: raw => {
388
+ const m = raw.match(/([\d.]+)/);
389
+ if (!m) return null;
390
+ return { version: m[1], extra: [] };
391
+ },
392
+ // qwen-code version
393
+ qwenCode: raw => {
394
+ const m = raw.match(/([\d.]+)/);
395
+ if (!m) return null;
396
+ return { version: m[1], extra: [] };
397
+ },
398
+ // gemini version
399
+ gemini: raw => {
400
+ const m = raw.match(/([\d.]+)/);
401
+ if (!m) return null;
402
+ return { version: m[1], extra: [] };
403
+ },
404
+ };
405
+
406
+ /**
407
+ * Parse a raw version string using the per-tool parser, returning uniform format:
408
+ * "<version>" or "<version> (<extra1>, <extra2>, ...)"
409
+ * Falls back to the raw string if no parser matches.
410
+ * @param {string} key - Tool key (must match a key in VERSION_PARSERS)
411
+ * @param {string} raw - Raw version string from command output
412
+ * @returns {string} Parsed version string in uniform format
413
+ */
414
+ export function parseVersion(key, raw) {
415
+ if (!raw) return raw;
416
+ const parser = VERSION_PARSERS[key];
417
+ if (!parser) return raw;
418
+ const result = parser(raw);
419
+ if (!result) return raw;
420
+ const { version, extra } = result;
421
+ if (extra && extra.length > 0) {
422
+ return `${version} (${extra.join(', ')})`;
423
+ }
424
+ return version;
425
+ }
426
+
36
427
  /**
37
428
  * Command definitions for version checking
38
429
  * Each entry has: key, command, and optional fallbacks
@@ -57,8 +448,8 @@ const VERSION_COMMANDS = [
57
448
 
58
449
  // Browsers (installed via Playwright)
59
450
  { key: 'chrome', command: 'google-chrome --version 2>&1' },
60
- { key: 'chromium', command: 'chromium --version 2>&1', fallbacks: ['chromium-browser --version 2>&1'] },
61
- { key: 'firefox', command: 'firefox --version 2>&1' },
451
+ { key: 'chromium', command: 'chromium --version 2>&1', fallbacks: ['chromium-browser --version 2>&1', "ls ~/.cache/ms-playwright/ 2>/dev/null | grep -oE 'chromium-[0-9]+' | head -1"] },
452
+ { key: 'firefox', command: 'firefox --version 2>&1', fallbacks: ["ls ~/.cache/ms-playwright/ 2>/dev/null | grep -oE 'firefox-[0-9]+' | head -1"] },
62
453
  { key: 'msedge', command: 'microsoft-edge --version 2>&1', fallbacks: ['microsoft-edge-stable --version 2>&1'] },
63
454
  { key: 'webkit', command: "ls ~/.cache/ms-playwright/ 2>/dev/null | grep -oE 'webkit-[0-9]+' | head -1" },
64
455
 
@@ -109,7 +500,7 @@ const VERSION_COMMANDS = [
109
500
  { key: 'gpp', command: 'g++ --version 2>&1 | head -n1' },
110
501
  { key: 'clang', command: 'clang --version 2>&1 | head -n1' },
111
502
  { key: 'llvm', command: 'llvm-config --version 2>&1' },
112
- { key: 'lld', command: 'lld --version 2>&1 | head -n1' },
503
+ { key: 'lld', command: 'ld.lld --version 2>&1 | head -n1', fallbacks: ['lld --version 2>&1 | head -n1'] },
113
504
  { key: 'make', command: 'make --version 2>&1 | head -n1' },
114
505
  { key: 'cmake', command: 'cmake --version 2>&1 | head -n1' },
115
506
 
@@ -139,7 +530,7 @@ const VERSION_COMMANDS = [
139
530
  { key: 'unzip', command: 'unzip -v 2>&1 | head -n1' },
140
531
  { key: 'expect', command: 'expect -version 2>&1' },
141
532
  { key: 'screen', command: 'screen --version 2>&1' },
142
- { key: 'xvfb', command: 'Xvfb -version 2>&1 | head -n1', fallbacks: ['dpkg -l xvfb 2>/dev/null | grep xvfb | head -1'] },
533
+ { key: 'xvfb', command: 'dpkg -l xvfb 2>/dev/null | grep "^ii" | head -1', fallbacks: ['Xvfb -version 2>&1 | head -n1'] },
143
534
  ];
144
535
 
145
536
  /**
@@ -353,14 +744,17 @@ export async function getVersionInfo(verbose = false, processVersion = null) {
353
744
  }
354
745
 
355
746
  /**
356
- * Helper to add version line if version exists
747
+ * Helper to add version line if version exists.
748
+ * Uses parseVersion() to normalize raw output into uniform format.
357
749
  * @param {string[]} lines - Array to push to
358
750
  * @param {string} label - Display label
359
751
  * @param {string|null} version - Version string or null
752
+ * @param {string} [key] - Tool key for version parser lookup
360
753
  */
361
- function addVersionLine(lines, label, version) {
754
+ function addVersionLine(lines, label, version, key) {
362
755
  if (version) {
363
- lines.push(`• ${label}: \`${version}\``);
756
+ const display = key ? parseVersion(key, version) : version;
757
+ lines.push(`• ${label}: \`${display}\``);
364
758
  }
365
759
  }
366
760
 
@@ -384,13 +778,13 @@ export function formatVersionMessage(versions) {
384
778
 
385
779
  // === AI Agents (--tool options) ===
386
780
  const agentLines = [];
387
- addVersionLine(agentLines, 'Claude Code', versions.claudeCode);
388
- addVersionLine(agentLines, 'Agent CLI', versions.agent);
389
- addVersionLine(agentLines, 'OpenAI Codex', versions.codex);
390
- addVersionLine(agentLines, 'OpenCode', versions.opencode);
391
- addVersionLine(agentLines, 'Qwen Code', versions.qwenCode);
392
- addVersionLine(agentLines, 'Gemini CLI', versions.gemini);
393
- addVersionLine(agentLines, 'GitHub Copilot', versions.copilot);
781
+ addVersionLine(agentLines, 'Claude Code', versions.claudeCode, 'claudeCode');
782
+ addVersionLine(agentLines, 'Agent CLI', versions.agent, 'agent');
783
+ addVersionLine(agentLines, 'OpenAI Codex', versions.codex, 'codex');
784
+ addVersionLine(agentLines, 'OpenCode', versions.opencode, 'opencode');
785
+ addVersionLine(agentLines, 'Qwen Code', versions.qwenCode, 'qwenCode');
786
+ addVersionLine(agentLines, 'Gemini CLI', versions.gemini, 'gemini');
787
+ addVersionLine(agentLines, 'GitHub Copilot', versions.copilot, 'copilot');
394
788
 
395
789
  if (agentLines.length > 0) {
396
790
  lines.push('');
@@ -402,7 +796,7 @@ export function formatVersionMessage(versions) {
402
796
  const jsLines = [];
403
797
  addVersionLine(jsLines, 'Node.js', versions.node);
404
798
  addVersionLine(jsLines, 'Bun', versions.bun);
405
- addVersionLine(jsLines, 'Deno', versions.deno);
799
+ addVersionLine(jsLines, 'Deno', versions.deno, 'deno');
406
800
  addVersionLine(jsLines, 'NPM', versions.npm);
407
801
  addVersionLine(jsLines, 'NVM', versions.nvm);
408
802
 
@@ -414,8 +808,8 @@ export function formatVersionMessage(versions) {
414
808
 
415
809
  // === Python ===
416
810
  const pythonLines = [];
417
- addVersionLine(pythonLines, 'Python', versions.python);
418
- addVersionLine(pythonLines, 'Pyenv', versions.pyenv);
811
+ addVersionLine(pythonLines, 'Python', versions.python, 'python');
812
+ addVersionLine(pythonLines, 'Pyenv', versions.pyenv, 'pyenv');
419
813
 
420
814
  if (pythonLines.length > 0) {
421
815
  lines.push('');
@@ -425,8 +819,8 @@ export function formatVersionMessage(versions) {
425
819
 
426
820
  // === Rust ===
427
821
  const rustLines = [];
428
- addVersionLine(rustLines, 'Rustc', versions.rust);
429
- addVersionLine(rustLines, 'Cargo', versions.cargo);
822
+ addVersionLine(rustLines, 'Rustc', versions.rust, 'rust');
823
+ addVersionLine(rustLines, 'Cargo', versions.cargo, 'cargo');
430
824
 
431
825
  if (rustLines.length > 0) {
432
826
  lines.push('');
@@ -436,7 +830,7 @@ export function formatVersionMessage(versions) {
436
830
 
437
831
  // === Java ===
438
832
  const javaLines = [];
439
- addVersionLine(javaLines, 'Java', versions.java);
833
+ addVersionLine(javaLines, 'Java', versions.java, 'java');
440
834
  addVersionLine(javaLines, 'SDKMAN', versions.sdkman);
441
835
 
442
836
  if (javaLines.length > 0) {
@@ -449,14 +843,14 @@ export function formatVersionMessage(versions) {
449
843
  if (versions.go) {
450
844
  lines.push('');
451
845
  lines.push('*🔷 Go*');
452
- addVersionLine(lines, 'Go', versions.go);
846
+ addVersionLine(lines, 'Go', versions.go, 'go');
453
847
  }
454
848
 
455
849
  // === PHP ===
456
850
  if (versions.php) {
457
851
  lines.push('');
458
852
  lines.push('*🐘 PHP*');
459
- addVersionLine(lines, 'PHP', versions.php);
853
+ addVersionLine(lines, 'PHP', versions.php, 'php');
460
854
  }
461
855
 
462
856
  // === .NET ===
@@ -469,7 +863,7 @@ export function formatVersionMessage(versions) {
469
863
  // === Perl ===
470
864
  const perlLines = [];
471
865
  addVersionLine(perlLines, 'Perl', versions.perl);
472
- addVersionLine(perlLines, 'Perlbrew', versions.perlbrew);
866
+ addVersionLine(perlLines, 'Perlbrew', versions.perlbrew, 'perlbrew');
473
867
 
474
868
  if (perlLines.length > 0) {
475
869
  lines.push('');
@@ -479,9 +873,9 @@ export function formatVersionMessage(versions) {
479
873
 
480
874
  // === OCaml/Rocq ===
481
875
  const ocamlLines = [];
482
- addVersionLine(ocamlLines, 'OCaml', versions.ocaml);
876
+ addVersionLine(ocamlLines, 'OCaml', versions.ocaml, 'ocaml');
483
877
  addVersionLine(ocamlLines, 'Opam', versions.opam);
484
- addVersionLine(ocamlLines, 'Rocq/Coq', versions.rocq);
878
+ addVersionLine(ocamlLines, 'Rocq/Coq', versions.rocq, 'rocq');
485
879
 
486
880
  if (ocamlLines.length > 0) {
487
881
  lines.push('');
@@ -491,8 +885,8 @@ export function formatVersionMessage(versions) {
491
885
 
492
886
  // === Lean ===
493
887
  const leanLines = [];
494
- addVersionLine(leanLines, 'Lean', versions.lean);
495
- addVersionLine(leanLines, 'Elan', versions.elan);
888
+ addVersionLine(leanLines, 'Lean', versions.lean, 'lean');
889
+ addVersionLine(leanLines, 'Elan', versions.elan, 'elan');
496
890
  addVersionLine(leanLines, 'Lake', versions.lake);
497
891
 
498
892
  if (leanLines.length > 0) {
@@ -503,8 +897,8 @@ export function formatVersionMessage(versions) {
503
897
 
504
898
  // === Ruby ===
505
899
  const rubyLines = [];
506
- addVersionLine(rubyLines, 'Ruby', versions.ruby);
507
- addVersionLine(rubyLines, 'Rbenv', versions.rbenv);
900
+ addVersionLine(rubyLines, 'Ruby', versions.ruby, 'ruby');
901
+ addVersionLine(rubyLines, 'Rbenv', versions.rbenv, 'rbenv');
508
902
 
509
903
  if (rubyLines.length > 0) {
510
904
  lines.push('');
@@ -516,34 +910,34 @@ export function formatVersionMessage(versions) {
516
910
  if (versions.kotlin) {
517
911
  lines.push('');
518
912
  lines.push('*🟣 Kotlin*');
519
- addVersionLine(lines, 'Kotlin', versions.kotlin);
913
+ addVersionLine(lines, 'Kotlin', versions.kotlin, 'kotlin');
520
914
  }
521
915
 
522
916
  // === Swift ===
523
917
  if (versions.swift) {
524
918
  lines.push('');
525
919
  lines.push('*🦅 Swift*');
526
- addVersionLine(lines, 'Swift', versions.swift);
920
+ addVersionLine(lines, 'Swift', versions.swift, 'swift');
527
921
  }
528
922
 
529
923
  // === R ===
530
924
  if (versions.r) {
531
925
  lines.push('');
532
926
  lines.push('*📊 R*');
533
- addVersionLine(lines, 'R', versions.r);
927
+ addVersionLine(lines, 'R', versions.r, 'r');
534
928
  }
535
929
 
536
930
  // === C/C++ ===
537
931
  const cppLines = [];
538
- addVersionLine(cppLines, 'GCC', versions.gcc);
539
- addVersionLine(cppLines, 'G++', versions.gpp);
540
- addVersionLine(cppLines, 'Clang', versions.clang);
932
+ addVersionLine(cppLines, 'GCC', versions.gcc, 'gcc');
933
+ addVersionLine(cppLines, 'G++', versions.gpp, 'gpp');
934
+ addVersionLine(cppLines, 'Clang', versions.clang, 'clang');
541
935
  addVersionLine(cppLines, 'LLVM', versions.llvm);
542
- addVersionLine(cppLines, 'LLD', versions.lld);
543
- addVersionLine(cppLines, 'Make', versions.make);
544
- addVersionLine(cppLines, 'CMake', versions.cmake);
545
- addVersionLine(cppLines, 'NASM', versions.nasm);
546
- addVersionLine(cppLines, 'FASM', versions.fasm);
936
+ addVersionLine(cppLines, 'LLD', versions.lld, 'lld');
937
+ addVersionLine(cppLines, 'Make', versions.make, 'make');
938
+ addVersionLine(cppLines, 'CMake', versions.cmake, 'cmake');
939
+ addVersionLine(cppLines, 'NASM', versions.nasm, 'nasm');
940
+ addVersionLine(cppLines, 'FASM', versions.fasm, 'fasm');
547
941
 
548
942
  if (cppLines.length > 0) {
549
943
  lines.push('');
@@ -553,10 +947,10 @@ export function formatVersionMessage(versions) {
553
947
 
554
948
  // === Browsers ===
555
949
  const browserLines = [];
556
- addVersionLine(browserLines, 'Google Chrome', versions.chrome);
557
- addVersionLine(browserLines, 'Chromium', versions.chromium);
558
- addVersionLine(browserLines, 'Firefox', versions.firefox);
559
- addVersionLine(browserLines, 'Microsoft Edge', versions.msedge);
950
+ addVersionLine(browserLines, 'Google Chrome', versions.chrome, 'chrome');
951
+ addVersionLine(browserLines, 'Chromium', versions.chromium, 'chromium');
952
+ addVersionLine(browserLines, 'Firefox', versions.firefox, 'firefox');
953
+ addVersionLine(browserLines, 'Microsoft Edge', versions.msedge, 'msedge');
560
954
  addVersionLine(browserLines, 'WebKit', versions.webkit);
561
955
 
562
956
  if (browserLines.length > 0) {
@@ -567,15 +961,15 @@ export function formatVersionMessage(versions) {
567
961
 
568
962
  // === Browser Automation ===
569
963
  const browserAutoLines = [];
570
- addVersionLine(browserAutoLines, 'Playwright', versions.playwright);
571
- addVersionLine(browserAutoLines, 'Playwright Test', versions.playwrightTest);
572
- addVersionLine(browserAutoLines, 'Playwright MCP', versions.playwrightMcp);
573
- if (versions.playwrightMcpStatus) {
574
- browserAutoLines.push(`• Playwright MCP in Claude Code: \`${versions.playwrightMcpStatus}\``);
575
- } else if (versions.playwrightMcp) {
576
- browserAutoLines.push('• Playwright MCP in Claude Code: `not configured`');
964
+ addVersionLine(browserAutoLines, 'Playwright', versions.playwright, 'playwright');
965
+ addVersionLine(browserAutoLines, 'Playwright Test', versions.playwrightTest, 'playwrightTest');
966
+ // Playwright MCP: show version with Claude Code connection status inline
967
+ if (versions.playwrightMcp) {
968
+ const mcpVersion = parseVersion('playwrightMcp', versions.playwrightMcp);
969
+ const claudeStatus = versions.playwrightMcpStatus ? 'connected' : 'not connected';
970
+ browserAutoLines.push(`• Playwright MCP: \`${mcpVersion} | Claude Code: ${claudeStatus}\``);
577
971
  }
578
- addVersionLine(browserAutoLines, 'Puppeteer Browsers', versions.puppeteerBrowsers);
972
+ addVersionLine(browserAutoLines, 'Puppeteer Browsers', versions.puppeteerBrowsers, 'puppeteerBrowsers');
579
973
 
580
974
  if (browserAutoLines.length > 0) {
581
975
  lines.push('');
@@ -585,17 +979,17 @@ export function formatVersionMessage(versions) {
585
979
 
586
980
  // === Development Tools ===
587
981
  const toolLines = [];
588
- addVersionLine(toolLines, 'Git', versions.git);
589
- addVersionLine(toolLines, 'GitHub CLI', versions.gh);
590
- addVersionLine(toolLines, 'GitLab CLI', versions.glab);
591
- addVersionLine(toolLines, 'Homebrew', versions.brew);
592
- addVersionLine(toolLines, 'cURL', versions.curl);
593
- addVersionLine(toolLines, 'Wget', versions.wget);
594
- addVersionLine(toolLines, 'Zip', versions.zip);
595
- addVersionLine(toolLines, 'Unzip', versions.unzip);
596
- addVersionLine(toolLines, 'Expect', versions.expect);
597
- addVersionLine(toolLines, 'Screen', versions.screen);
598
- addVersionLine(toolLines, 'Xvfb', versions.xvfb);
982
+ addVersionLine(toolLines, 'Git', versions.git, 'git');
983
+ addVersionLine(toolLines, 'GitHub CLI', versions.gh, 'gh');
984
+ addVersionLine(toolLines, 'GitLab CLI', versions.glab, 'glab');
985
+ addVersionLine(toolLines, 'Homebrew', versions.brew, 'brew');
986
+ addVersionLine(toolLines, 'cURL', versions.curl, 'curl');
987
+ addVersionLine(toolLines, 'Wget', versions.wget, 'wget');
988
+ addVersionLine(toolLines, 'Zip', versions.zip, 'zip');
989
+ addVersionLine(toolLines, 'Unzip', versions.unzip, 'unzip');
990
+ addVersionLine(toolLines, 'Expect', versions.expect, 'expect');
991
+ addVersionLine(toolLines, 'Screen', versions.screen, 'screen');
992
+ addVersionLine(toolLines, 'Xvfb', versions.xvfb, 'xvfb');
599
993
 
600
994
  if (toolLines.length > 0) {
601
995
  lines.push('');
@@ -616,4 +1010,5 @@ export function formatVersionMessage(versions) {
616
1010
  export default {
617
1011
  getVersionInfo,
618
1012
  formatVersionMessage,
1013
+ parseVersion,
619
1014
  };