@link-assistant/hive-mind 1.68.0 → 1.69.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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.69.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8939a2a: Add experimental `--show-limits` virtual option to hive-telegram-bot's `/solve` and `/hive` commands. When set, the bot embeds a Claude (or Codex) usage snapshot in the executing message and a delta block (start → end, with a parallel-sessions disclaimer) in the completion message. Limits are fetched via the existing 20-minute cached helpers so the upstream usage API isn't rate-limited. The flag is stripped before the args reach `/solve` or `/hive`, and bot administrators can disable it with `TELEGRAM_SHOW_LIMITS=false`. Refs: #594.
8
+
3
9
  ## 1.68.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.68.0",
3
+ "version": "1.69.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",
@@ -281,6 +281,36 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
281
281
  }
282
282
  }
283
283
 
284
+ // Issue #594: when --show-limits was used at command time, capture an
285
+ // end-of-task limits snapshot and append a delta block to the
286
+ // completion message. The cached helpers respect a 20-min TTL so
287
+ // parallel sessions don't stampede the upstream API.
288
+ const limitsExtraSections = [];
289
+ if (sessionInfo?.showLimits) {
290
+ try {
291
+ const showLimitsLib = await import('./telegram-show-limits.lib.mjs');
292
+ const limitsLib = await import('./limits.lib.mjs');
293
+ const endSnapshot = await showLimitsLib.captureLimitsSnapshot({
294
+ tool: sessionInfo.tool || 'claude',
295
+ verbose,
296
+ limitsLib,
297
+ });
298
+ sessionInfo.limitsAtEnd = endSnapshot;
299
+ const deltaBlock = showLimitsLib.formatLimitsDeltaBlock(sessionInfo.limitsAtStart || null, endSnapshot);
300
+ if (deltaBlock) limitsExtraSections.push(deltaBlock);
301
+ else {
302
+ // Either start snapshot was missing or tool changed — fall back
303
+ // to a plain end-of-task snapshot so the user still sees current state.
304
+ const endBlock = showLimitsLib.formatLimitsSnapshotBlock(endSnapshot, { title: '📊 Limits at end' });
305
+ if (endBlock) limitsExtraSections.push(endBlock);
306
+ }
307
+ } catch (limitsError) {
308
+ if (verbose) {
309
+ console.log(`[VERBOSE] Could not capture end-of-task limits for ${sessionName}: ${limitsError?.message || limitsError}`);
310
+ }
311
+ }
312
+ }
313
+
284
314
  const message = formatSessionCompletionMessage({
285
315
  sessionName,
286
316
  sessionInfo,
@@ -289,6 +319,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
289
319
  exitCode: finalExitCode,
290
320
  infoBlock: sessionInfo?.infoBlock || '',
291
321
  pullRequestUrl,
322
+ extraSections: limitsExtraSections,
292
323
  });
293
324
 
294
325
  // Update the original reply message if messageId is available, otherwise send new message
@@ -30,12 +30,14 @@ const { parseGitHubUrl, validateGitHubEntityExistence } = await import('./github
30
30
  const { validateModelName, buildModelOptionDescription } = await import('./models/index.mjs');
31
31
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
32
32
  const { extractIsolationFromArgs, isValidPerCommandIsolation, resolveIsolation, createIsolationAwareQueueCallback } = await import('./telegram-isolation.lib.mjs');
33
- const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = await import('./limits.lib.mjs');
33
+ const limitsLib = await import('./limits.lib.mjs');
34
+ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = limitsLib;
35
+ const { handleShowLimitsFlag, captureStartSnapshotAndAppend } = await import('./telegram-show-limits.lib.mjs'); // #594
34
36
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
35
37
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
36
38
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
37
39
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
38
- const { executeStartScreen: executeStartScreenCommand } = await import('./telegram-command-execution.lib.mjs');
40
+ const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
39
41
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
40
42
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
41
43
  const { safeReply } = await import('./telegram-safe-reply.lib.mjs');
@@ -110,6 +112,8 @@ const config = yargs(hideBin(process.argv))
110
112
  default: getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true',
111
113
  })
112
114
  .option('autoStartScreenWatchMessage', { type: 'boolean', description: 'Experimental: auto-start separate /terminal_watch messages for public /solve sessions', alias: 'auto-start-screen-watch-message', default: getenv('TELEGRAM_AUTO_START_SCREEN_WATCH_MESSAGE', getenv('TELEGRAM_AUTO_WATCH_MESSAGE', 'false')) === 'true' })
115
+ // Issue #594: bot-owner toggle for --show-limits virtual option in /solve and /hive.
116
+ .option('showLimits', { type: 'boolean', description: 'Experimental: allow /solve and /hive callers to use --show-limits to embed Claude/Codex usage at start, end, and delta in the completion message', alias: 'show-limits', default: getenv('TELEGRAM_SHOW_LIMITS', 'true') !== 'false' })
113
117
  .option('isolation', { type: 'string', description: "Isolation backend (screen/tmux/docker). Defaults to 'screen' so Telegram-bot work sessions survive bot restarts; pass --isolation '' (or set TELEGRAM_ISOLATION='') to disable.", default: getenv('TELEGRAM_ISOLATION', 'screen') })
114
118
  .help('h')
115
119
  .alias('h', 'help')
@@ -128,6 +132,7 @@ if (config.configuration) {
128
132
  const BOT_TOKEN = config.token || getenv('TELEGRAM_BOT_TOKEN', '');
129
133
  const VERBOSE = config.verbose || getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true';
130
134
  const AUTO_WATCH_MESSAGE = config.autoStartScreenWatchMessage === true;
135
+ const SHOW_LIMITS_ENABLED = config.showLimits === true;
131
136
  if (!BOT_TOKEN) {
132
137
  console.error('Error: TELEGRAM_BOT_TOKEN not set. Use --token or TELEGRAM_BOT_TOKEN env var.');
133
138
  process.exit(1);
@@ -460,48 +465,7 @@ async function validateGitHubUrl(args, options = {}) {
460
465
  return { valid: true, parsed, normalizedUrl: url };
461
466
  }
462
467
 
463
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null) {
464
- const { chat, message_id: msgId } = startingMessage;
465
- const safeEdit = async text => {
466
- try {
467
- await ctx.telegram.editMessageText(chat.id, msgId, undefined, text, { parse_mode: 'Markdown' });
468
- } catch (e) {
469
- console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
470
- }
471
- };
472
- const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
473
- const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
474
- let result, session, sessionInfo;
475
- if (iso) {
476
- session = iso.runner.generateSessionId();
477
- VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
478
- result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
479
- if (result.success) {
480
- sessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId };
481
- trackSession(session, sessionInfo, VERBOSE);
482
- }
483
- } else {
484
- result = await executeStartScreen(commandName, args);
485
- const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
486
- session = match ? match[1] : 'unknown';
487
- // Issue #1586: Non-isolation sessions auto-expire after 10 min — screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
488
- if (result.success && session !== 'unknown') {
489
- sessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId };
490
- trackSession(session, sessionInfo, VERBOSE);
491
- }
492
- }
493
- if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
494
- if (result.success) {
495
- await safeEdit(
496
- formatExecutingWorkSessionMessage({
497
- sessionName: session,
498
- isolationBackend: iso?.backend || null,
499
- infoBlock,
500
- })
501
- );
502
- if (AUTO_WATCH_MESSAGE && commandName === 'solve' && sessionInfo?.isolationBackend) await startAutoTerminalWatchForSession({ bot, ctx, sessionId: session, sessionInfo, verbose: VERBOSE });
503
- } else await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\`\n\n${infoBlock}`);
504
- }
468
+ const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage });
505
469
 
506
470
  bot.command('help', async ctx => {
507
471
  VERBOSE && console.log('[VERBOSE] /help command received');
@@ -603,6 +567,7 @@ bot.command('help', async ctx => {
603
567
  message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
604
568
  message += '• `--think <level>` - Thinking level (off/low/medium/high/xhigh/max) | `--thinking-budget <num>` - Token budget (0-63999)\n';
605
569
  message += '• `--verbose` or `-v` - Verbose output | `--attach-logs` - Attach logs to PR\n';
570
+ if (SHOW_LIMITS_ENABLED) message += '• `--show-limits` - Experimental: embed Claude/Codex usage at start, end and delta (#594)\n';
606
571
  message += '\n💡 *Tip:* Many more options available. See full documentation for complete list.\n';
607
572
 
608
573
  if (allowedChats || allowedTopics) {
@@ -788,6 +753,12 @@ async function handleSolveCommand(ctx) {
788
753
  const solveToolAlias = getSolveToolAliasFromText(ctx.message.text);
789
754
  let userArgs = parseCommandArgs(ctx.message.text);
790
755
 
756
+ // Issue #594: strip --show-limits from userArgs (hive-telegram-bot virtual option).
757
+ const solveSL = await handleShowLimitsFlag({ ctx, safeReply, args: userArgs, enabled: SHOW_LIMITS_ENABLED });
758
+ if (solveSL.handled) return;
759
+ const solveShowLimits = solveSL.showLimits;
760
+ userArgs = solveSL.args;
761
+
791
762
  // Check if this is a reply to a message and user didn't provide URL as first argument
792
763
  // In that case, try to extract GitHub URL from the replied message
793
764
  // Issue #1325: Support all options via /solve command when replying (e.g., "/solve --model opus")
@@ -958,11 +929,16 @@ async function handleSolveCommand(ctx) {
958
929
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
959
930
  // Issue #378: propagate user's effective Telegram locale to the spawned solve session.
960
931
  const argsWithLocale = injectLanguageIfMissing(args, solveLocale);
932
+
933
+ // Issue #594: append "Limits at start" to infoBlock; thread snapshot via sessionInfo.
934
+ let solveLimitsAtStart = null;
935
+ if (solveShowLimits) ({ infoBlock, limitsAtStart: solveLimitsAtStart } = await captureStartSnapshotAndAppend({ infoBlock, tool: solveTool, verbose: VERBOSE, limitsLib, commandLabel: '/solve' }));
936
+
961
937
  if (check.canStart && toolQueuedCount === 0) {
962
938
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
963
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext);
939
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart });
964
940
  } else {
965
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext });
941
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart });
966
942
  let queueMessage = `📋 Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
967
943
  if (check.reason) queueMessage += `\n\n⏳ Waiting: ${escapeMarkdown(check.reason)}`;
968
944
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
@@ -1049,7 +1025,13 @@ async function handleHiveCommand(ctx) {
1049
1025
 
1050
1026
  VERBOSE && console.log('[VERBOSE] /hive passed all checks, executing...');
1051
1027
 
1052
- const userArgs = parseCommandArgs(ctx.message.text);
1028
+ let userArgs = parseCommandArgs(ctx.message.text);
1029
+
1030
+ // Issue #594: see /solve handler.
1031
+ const hiveSL = await handleShowLimitsFlag({ ctx, safeReply, args: userArgs, enabled: SHOW_LIMITS_ENABLED });
1032
+ if (hiveSL.handled) return;
1033
+ const hiveShowLimits = hiveSL.showLimits;
1034
+ userArgs = hiveSL.args;
1053
1035
 
1054
1036
  // Issue #1102: Allow issues_list/pulls_list URLs and normalize to repo URLs
1055
1037
  const validation = await validateGitHubUrl(userArgs, { allowedTypes: ['repo', 'organization', 'user', 'issues_list', 'pulls_list'], commandName: 'hive', createYargsConfig: createHiveYargsConfig, positionalNames: ['github-url'] });
@@ -1125,10 +1107,14 @@ async function handleHiveCommand(ctx) {
1125
1107
  infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}🔒 Locked options: ${escapeMarkdown(hiveOverrides.join(' '))}`;
1126
1108
  }
1127
1109
 
1110
+ // Issue #594: see /solve handler.
1111
+ let hiveLimitsAtStart = null;
1112
+ if (hiveShowLimits) ({ infoBlock, limitsAtStart: hiveLimitsAtStart } = await captureStartSnapshotAndAppend({ infoBlock, tool: hiveTool, verbose: VERBOSE, limitsLib, commandLabel: '/hive' }));
1113
+
1128
1114
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
1129
1115
  // Issue #378: propagate user's effective Telegram locale to the spawned hive session.
1130
1116
  const hiveArgsWithLocale = injectLanguageIfMissing(args, hiveLocale);
1131
- await executeAndUpdateMessage(ctx, startingMessage, 'hive', hiveArgsWithLocale, infoBlock, effectiveHiveIsolation, hiveTool);
1117
+ await executeAndUpdateMessage(ctx, startingMessage, 'hive', hiveArgsWithLocale, infoBlock, effectiveHiveIsolation, hiveTool, null, { showLimits: hiveShowLimits, limitsAtStart: hiveLimitsAtStart });
1132
1118
  }
1133
1119
 
1134
1120
  bot.command(/^hive$/i, handleHiveCommand);
@@ -73,6 +73,70 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
73
73
  });
74
74
  }
75
75
 
76
+ /**
77
+ * Build the executeAndUpdateMessage function used by /solve and /hive in
78
+ * telegram-bot.mjs. The original function captures ~10 module-level closures
79
+ * (resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen,
80
+ * trackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot,
81
+ * formatExecutingWorkSessionMessage); the factory pattern lets us extract the
82
+ * function while still keeping all those handles available without making them
83
+ * module-global elsewhere. Splitting this out keeps telegram-bot.mjs under the
84
+ * 1500-line cap (issues #1141, #1730, #594).
85
+ *
86
+ * @param {Object} deps - Dependencies captured at bot startup time.
87
+ * @param {Function} deps.resolveIsolation
88
+ * @param {string|null} deps.ISOLATION_BACKEND
89
+ * @param {Object} deps.isolationRunner
90
+ * @param {boolean} deps.VERBOSE
91
+ * @param {Function} deps.executeStartScreen
92
+ * @param {Function} deps.trackSession
93
+ * @param {boolean} deps.AUTO_WATCH_MESSAGE
94
+ * @param {Function} deps.startAutoTerminalWatchForSession
95
+ * @param {Object} deps.bot
96
+ * @param {Function} deps.formatExecutingWorkSessionMessage
97
+ * @returns {Function} executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation, tool, urlContext, sessionExtras)
98
+ */
99
+ export function buildExecuteAndUpdateMessage(deps) {
100
+ const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage } = deps;
101
+ return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null } = {}) {
102
+ const { chat, message_id: msgId } = startingMessage;
103
+ const safeEdit = async text => {
104
+ try {
105
+ await ctx.telegram.editMessageText(chat.id, msgId, undefined, text, { parse_mode: 'Markdown' });
106
+ } catch (e) {
107
+ console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
108
+ }
109
+ };
110
+ const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
111
+ const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart }; // #594: showLimits/limitsAtStart
112
+ const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
113
+ let result, session, sessionInfo;
114
+ if (iso) {
115
+ session = iso.runner.generateSessionId();
116
+ VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
117
+ result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
118
+ if (result.success) {
119
+ sessionInfo = { ...baseSessionInfo, isolationBackend: iso.backend, sessionId: session };
120
+ trackSession(session, sessionInfo, VERBOSE);
121
+ }
122
+ } else {
123
+ result = await executeStartScreen(commandName, args);
124
+ const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
125
+ session = match ? match[1] : 'unknown';
126
+ // Issue #1586: Non-isolation sessions auto-expire after 10 min — screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
127
+ if (result.success && session !== 'unknown') {
128
+ sessionInfo = baseSessionInfo;
129
+ trackSession(session, sessionInfo, VERBOSE);
130
+ }
131
+ }
132
+ if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
133
+ if (result.success) {
134
+ await safeEdit(formatExecutingWorkSessionMessage({ sessionName: session, isolationBackend: iso?.backend || null, infoBlock }));
135
+ if (AUTO_WATCH_MESSAGE && commandName === 'solve' && sessionInfo?.isolationBackend) await startAutoTerminalWatchForSession({ bot, ctx, sessionId: session, sessionInfo, verbose: VERBOSE });
136
+ } else await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\`\n\n${infoBlock}`);
137
+ };
138
+ }
139
+
76
140
  export async function executeStartScreen(command, args, options = {}) {
77
141
  const { verbose = false } = options;
78
142
 
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Telegram bot virtual option: --show-limits (experimental).
3
+ *
4
+ * The --show-limits flag is intercepted by hive-telegram-bot and stripped from
5
+ * the args before they are forwarded to /solve, /hive (or /task --split). When
6
+ * set, the bot:
7
+ * 1. Fetches usage limits for the selected tool (Claude or Codex) using the
8
+ * shared cached helpers in limits.lib.mjs (TTL: 20 minutes for the usage
9
+ * API to avoid rate limiting).
10
+ * 2. Embeds a compact "Limits at start" snapshot below the infoBlock so the
11
+ * starting/executing message shows the user how much budget they had at
12
+ * the moment the command was queued.
13
+ * 3. Captures the snapshot in the per-session record so the completion
14
+ * message can render an end-of-task snapshot plus a delta. The delta is
15
+ * not exact — multiple parallel sessions all consume from the same
16
+ * budget — and is reported as such.
17
+ *
18
+ * The flag is purely a Telegram bot concern; downstream commands never see it.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/594
21
+ */
22
+
23
+ const SHOW_LIMITS_FLAG = '--show-limits';
24
+ const NO_SHOW_LIMITS_FLAG = '--no-show-limits';
25
+
26
+ /**
27
+ * Detect whether the user passed --show-limits (or --no-show-limits to opt out)
28
+ * and return a copy of the args without that flag.
29
+ *
30
+ * Last occurrence wins, matching how yargs resolves repeated boolean flags.
31
+ *
32
+ * @param {string[]} args
33
+ * @returns {{ showLimits: boolean|null, args: string[] }}
34
+ * `showLimits` is `true`/`false` when explicitly set, or `null` when absent.
35
+ */
36
+ export function extractShowLimitsFlag(args) {
37
+ if (!Array.isArray(args)) return { showLimits: null, args: args || [] };
38
+ let showLimits = null;
39
+ const filtered = [];
40
+ for (const arg of args) {
41
+ if (arg === SHOW_LIMITS_FLAG) {
42
+ showLimits = true;
43
+ continue;
44
+ }
45
+ if (arg === NO_SHOW_LIMITS_FLAG) {
46
+ showLimits = false;
47
+ continue;
48
+ }
49
+ if (arg === '--show-limits=true' || arg === '--show-limits=1') {
50
+ showLimits = true;
51
+ continue;
52
+ }
53
+ if (arg === '--show-limits=false' || arg === '--show-limits=0') {
54
+ showLimits = false;
55
+ continue;
56
+ }
57
+ filtered.push(arg);
58
+ }
59
+ return { showLimits, args: filtered };
60
+ }
61
+
62
+ /**
63
+ * Pick a tool key for limits selection. Codex-like tools route to Codex, the
64
+ * rest of the supported tools (claude, opencode, agent, gemini, qwen) route to
65
+ * Claude. This mirrors how solve.mjs selects which CLI to invoke.
66
+ *
67
+ * @param {string|null|undefined} tool
68
+ * @returns {'codex'|'claude'}
69
+ */
70
+ export function pickLimitsToolKey(tool) {
71
+ return String(tool || '').toLowerCase() === 'codex' ? 'codex' : 'claude';
72
+ }
73
+
74
+ /**
75
+ * Fetch the cached limits snapshot for the given tool. Returns a normalized
76
+ * shape that captures both the raw `success/usage/error` payload and a
77
+ * `toolKey` so the renderers know which formatter to use.
78
+ *
79
+ * @param {Object} options
80
+ * @param {string} [options.tool='claude']
81
+ * @param {boolean} [options.verbose=false]
82
+ * @param {{ getCachedClaudeLimits: Function, getCachedCodexLimits: Function }} options.limitsLib
83
+ * @returns {Promise<{ toolKey: 'codex'|'claude', success: boolean, usage?: any, error?: string, capturedAt: Date, additionalRateLimits?: any[], credits?: any, planType?: any }>}
84
+ */
85
+ export async function captureLimitsSnapshot({ tool = 'claude', verbose = false, limitsLib } = {}) {
86
+ if (!limitsLib) throw new Error('captureLimitsSnapshot requires limitsLib');
87
+ const toolKey = pickLimitsToolKey(tool);
88
+ const fetcher = toolKey === 'codex' ? limitsLib.getCachedCodexLimits : limitsLib.getCachedClaudeLimits;
89
+ const result = await fetcher(verbose);
90
+ return {
91
+ toolKey,
92
+ success: !!result?.success,
93
+ usage: result?.usage || null,
94
+ error: result?.success ? null : result?.error || 'Unknown error',
95
+ capturedAt: new Date(),
96
+ additionalRateLimits: result?.additionalRateLimits || null,
97
+ credits: result?.credits || null,
98
+ planType: result?.planType || null,
99
+ };
100
+ }
101
+
102
+ function pct(value) {
103
+ if (value === null || value === undefined) return null;
104
+ const num = Number(value);
105
+ if (!Number.isFinite(num)) return null;
106
+ return Math.floor(num);
107
+ }
108
+
109
+ function formatPercentage(value) {
110
+ const p = pct(value);
111
+ return p === null ? 'N/A' : `${p}%`;
112
+ }
113
+
114
+ /**
115
+ * Render a compact one-line-per-window summary of a Claude snapshot.
116
+ * The compact form is used inside a Markdown code block under the infoBlock.
117
+ *
118
+ * @param {Object} snapshot - Result of captureLimitsSnapshot for tool=claude
119
+ * @returns {string}
120
+ */
121
+ function formatClaudeSnapshotCompact(snapshot) {
122
+ if (!snapshot) return 'Claude limits: N/A';
123
+ if (!snapshot.success) return `Claude limits: ${snapshot.error || 'unavailable'}`;
124
+ const usage = snapshot.usage || {};
125
+ const lines = [];
126
+ lines.push(`5h session: ${formatPercentage(usage.currentSession?.percentage)}`);
127
+ lines.push(`7d all models: ${formatPercentage(usage.allModels?.percentage)}`);
128
+ if (usage.sonnetOnly && usage.sonnetOnly.percentage !== null && usage.sonnetOnly.percentage !== undefined) {
129
+ lines.push(`7d Sonnet only: ${formatPercentage(usage.sonnetOnly.percentage)}`);
130
+ }
131
+ return lines.join('\n');
132
+ }
133
+
134
+ function formatCodexSnapshotCompact(snapshot) {
135
+ if (!snapshot) return 'Codex limits: N/A';
136
+ if (!snapshot.success) return `Codex limits: ${snapshot.error || 'unavailable'}`;
137
+ const usage = snapshot.usage || {};
138
+ const lines = [];
139
+ lines.push(`5h session: ${formatPercentage(usage.currentSession?.percentage)}`);
140
+ lines.push(`Weekly: ${formatPercentage(usage.allModels?.percentage)}`);
141
+ return lines.join('\n');
142
+ }
143
+
144
+ /**
145
+ * Format a snapshot as a fenced code block suitable for prepending/appending
146
+ * inside the info block of a Telegram message.
147
+ *
148
+ * @param {Object} snapshot
149
+ * @param {Object} [options]
150
+ * @param {string} [options.title='📊 Limits at start'] Block title
151
+ * @returns {string}
152
+ */
153
+ export function formatLimitsSnapshotBlock(snapshot, { title = '📊 Limits at start' } = {}) {
154
+ if (!snapshot) return '';
155
+ const heading = snapshot.toolKey === 'codex' ? 'Codex' : 'Claude';
156
+ const body = snapshot.toolKey === 'codex' ? formatCodexSnapshotCompact(snapshot) : formatClaudeSnapshotCompact(snapshot);
157
+ return `${title} (${heading})\n\`\`\`\n${body}\n\`\`\``;
158
+ }
159
+
160
+ function deltaFor(startPct, endPct) {
161
+ const s = pct(startPct);
162
+ const e = pct(endPct);
163
+ if (s === null || e === null) return null;
164
+ return e - s;
165
+ }
166
+
167
+ function formatDeltaValue(delta) {
168
+ if (delta === null || delta === undefined) return 'N/A';
169
+ if (delta === 0) return '±0%';
170
+ const sign = delta > 0 ? '+' : '';
171
+ return `${sign}${delta}%`;
172
+ }
173
+
174
+ /**
175
+ * Format a "Limits change" block summarizing start, end, and delta for the
176
+ * configured windows. Includes a disclaimer about parallel sessions.
177
+ *
178
+ * @param {Object|null} startSnapshot
179
+ * @param {Object|null} endSnapshot
180
+ * @returns {string}
181
+ */
182
+ export function formatLimitsDeltaBlock(startSnapshot, endSnapshot) {
183
+ if (!startSnapshot || !endSnapshot) return '';
184
+ if (startSnapshot.toolKey !== endSnapshot.toolKey) return '';
185
+ const heading = startSnapshot.toolKey === 'codex' ? 'Codex' : 'Claude';
186
+ const lines = [];
187
+
188
+ if (!startSnapshot.success && !endSnapshot.success) {
189
+ lines.push(`Start: ${startSnapshot.error || 'unavailable'}`);
190
+ lines.push(`End: ${endSnapshot.error || 'unavailable'}`);
191
+ } else {
192
+ const startUsage = startSnapshot.usage || {};
193
+ const endUsage = endSnapshot.usage || {};
194
+
195
+ const sessionLabel = '5h session';
196
+ lines.push(`${sessionLabel}: ${formatPercentage(startUsage.currentSession?.percentage)} → ${formatPercentage(endUsage.currentSession?.percentage)} (${formatDeltaValue(deltaFor(startUsage.currentSession?.percentage, endUsage.currentSession?.percentage))})`);
197
+
198
+ const allModelsLabel = startSnapshot.toolKey === 'codex' ? 'Weekly' : '7d all models';
199
+ lines.push(`${allModelsLabel}: ${formatPercentage(startUsage.allModels?.percentage)} → ${formatPercentage(endUsage.allModels?.percentage)} (${formatDeltaValue(deltaFor(startUsage.allModels?.percentage, endUsage.allModels?.percentage))})`);
200
+
201
+ if (startSnapshot.toolKey === 'claude' && ((startUsage.sonnetOnly && startUsage.sonnetOnly.percentage !== null && startUsage.sonnetOnly.percentage !== undefined) || (endUsage.sonnetOnly && endUsage.sonnetOnly.percentage !== null && endUsage.sonnetOnly.percentage !== undefined))) {
202
+ lines.push(`7d Sonnet only: ${formatPercentage(startUsage.sonnetOnly?.percentage)} → ${formatPercentage(endUsage.sonnetOnly?.percentage)} (${formatDeltaValue(deltaFor(startUsage.sonnetOnly?.percentage, endUsage.sonnetOnly?.percentage))})`);
203
+ }
204
+ }
205
+
206
+ // Note: delta is not precise because multiple parallel tasks may consume
207
+ // from the same Anthropic/OpenAI budget windows during the run.
208
+ lines.push('Note: delta is approximate (parallel sessions share the same budget).');
209
+
210
+ return `📊 Limits change (${heading})\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
211
+ }
212
+
213
+ /**
214
+ * Append a free-form section (e.g. limits block) to an existing infoBlock,
215
+ * preserving Markdown structure. A blank line separates sections so the
216
+ * Telegram message stays readable.
217
+ *
218
+ * @param {string} infoBlock
219
+ * @param {string} addition
220
+ * @returns {string}
221
+ */
222
+ export function appendInfoSection(infoBlock, addition) {
223
+ const base = infoBlock || '';
224
+ const extra = addition || '';
225
+ if (!extra) return base;
226
+ if (!base) return extra;
227
+ return `${base}\n\n${extra}`;
228
+ }
229
+
230
+ /**
231
+ * High-level helper used by /solve and /hive: parse `--show-limits` out of the
232
+ * raw user args, decide whether the flag is honored (subject to the bot
233
+ * administrator's TELEGRAM_SHOW_LIMITS toggle), and either reply with a
234
+ * rejection message (returning {handled:true}) or hand back the stripped args
235
+ * and a boolean for the caller to thread through.
236
+ *
237
+ * @param {Object} options
238
+ * @param {Object} options.ctx - Telegraf context (used to reply on rejection)
239
+ * @param {Function} options.safeReply - safeReply(ctx, text, opts)
240
+ * @param {string[]} options.args - Raw user args before stripping
241
+ * @param {boolean} options.enabled - Master switch (config.showLimits)
242
+ * @returns {Promise<{ handled: boolean, args: string[], showLimits: boolean }>}
243
+ */
244
+ export async function handleShowLimitsFlag({ ctx, safeReply, args, enabled }) {
245
+ const { showLimits, args: stripped } = extractShowLimitsFlag(args);
246
+ if (showLimits === true && !enabled) {
247
+ await safeReply(ctx, '❌ `--show-limits` is disabled by the bot administrator.', { reply_to_message_id: ctx.message.message_id });
248
+ return { handled: true, args: stripped, showLimits: false };
249
+ }
250
+ return { handled: false, args: stripped, showLimits: showLimits === true && enabled };
251
+ }
252
+
253
+ /**
254
+ * Capture a "limits at start" snapshot for the given tool and append a
255
+ * formatted block to `infoBlock`. Errors are logged via the optional
256
+ * `verbose` flag and silently swallowed so a transient API failure does not
257
+ * abort the user's command.
258
+ *
259
+ * @param {Object} options
260
+ * @param {string} options.infoBlock - Existing infoBlock to append to
261
+ * @param {string} [options.tool='claude']
262
+ * @param {boolean} [options.verbose=false]
263
+ * @param {Object} options.limitsLib - { getCachedClaudeLimits, getCachedCodexLimits }
264
+ * @param {string} [options.commandLabel='command'] - For verbose logging
265
+ * @returns {Promise<{ infoBlock: string, limitsAtStart: Object|null }>}
266
+ */
267
+ export async function captureStartSnapshotAndAppend({ infoBlock, tool = 'claude', verbose = false, limitsLib, commandLabel = 'command' } = {}) {
268
+ let limitsAtStart = null;
269
+ let nextInfoBlock = infoBlock || '';
270
+ try {
271
+ limitsAtStart = await captureLimitsSnapshot({ tool, verbose, limitsLib });
272
+ const block = formatLimitsSnapshotBlock(limitsAtStart, { title: '📊 Limits at start' });
273
+ if (block) nextInfoBlock = appendInfoSection(nextInfoBlock, block);
274
+ } catch (e) {
275
+ if (verbose) console.log(`[VERBOSE] ${commandLabel} --show-limits snapshot failed: ${e?.message || e}`);
276
+ }
277
+ return { infoBlock: nextInfoBlock, limitsAtStart };
278
+ }
279
+
280
+ export const SHOW_LIMITS_FLAG_NAME = SHOW_LIMITS_FLAG;
281
+ export const NO_SHOW_LIMITS_FLAG_NAME = NO_SHOW_LIMITS_FLAG;
@@ -48,6 +48,9 @@ class SolveQueueItem {
48
48
  this.urlContext = options.urlContext || null;
49
49
  // Issue #1688: requester user ID for /subscribe duplicate-suppression.
50
50
  this.requesterUserId = options.ctx?.from?.id ?? null;
51
+ // Issue #594: --show-limits virtual option (hive-telegram-bot only).
52
+ this.showLimits = options.showLimits === true;
53
+ this.limitsAtStart = options.limitsAtStart || null;
51
54
  this.createdAt = new Date();
52
55
  this.startedAt = null;
53
56
  this.status = QueueItemStatus.QUEUED;
@@ -1407,6 +1410,11 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1407
1410
  // notifying the requester twice via /subscribe.
1408
1411
  urlContext: item.urlContext || null,
1409
1412
  requesterUserId: item.requesterUserId ?? null,
1413
+ // Issue #594: --show-limits virtual option carries the start-of-task
1414
+ // snapshot from the queueing point through to the completion
1415
+ // handler so it can render an end-of-task delta.
1416
+ showLimits: item.showLimits === true,
1417
+ limitsAtStart: item.limitsAtStart || null,
1410
1418
  });
1411
1419
  }
1412
1420
  }
@@ -83,7 +83,7 @@ export function appendPullRequestLine(infoBlock, pullRequestUrl) {
83
83
  return [...before, prLine, ...after].join('\n');
84
84
  }
85
85
 
86
- export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '', pullRequestUrl = null } = {}) {
86
+ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '', pullRequestUrl = null, extraSections = [] } = {}) {
87
87
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
88
88
  const failed = finalExitCode !== null && finalExitCode !== 0;
89
89
  const statusEmoji = failed ? '❌' : '✅';
@@ -102,5 +102,12 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
102
102
  message += `⏱️ Duration: ${formatSessionDurationSeconds(durationSeconds)}\n`;
103
103
  message += `📊 Session: \`${sessionName || 'unknown'}\`${isolationInfo}${details}`;
104
104
 
105
+ // Issue #594: --show-limits virtual option appends snapshot/delta sections
106
+ // (Markdown code blocks) below the standard completion details.
107
+ const extras = (Array.isArray(extraSections) ? extraSections : []).filter(Boolean);
108
+ if (extras.length > 0) {
109
+ message += `\n\n${extras.join('\n\n')}`;
110
+ }
111
+
105
112
  return message;
106
113
  }