@link-assistant/hive-mind 1.37.4 → 1.38.1

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.38.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1525ecb: fix: prevent 'Failed to send formatted message' Telegram error by adding safeReply helper and escaping unescaped Markdown in bot messages
8
+
9
+ ## 1.38.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ee331ef: Enhance --tokens-budget-stats with sub-session tracking, stream comparison, and GitHub comment display
14
+
3
15
  ## 1.37.4
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.37.4",
3
+ "version": "1.38.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -4,6 +4,135 @@
4
4
 
5
5
  import { formatNumber } from './claude.lib.mjs';
6
6
 
7
+ /**
8
+ * Helper: creates a fresh sub-session usage object for tracking tokens between compactification events
9
+ * @returns {Object} Empty sub-session usage structure
10
+ */
11
+ export const createEmptySubSessionUsage = () => ({
12
+ inputTokens: 0,
13
+ cacheCreationTokens: 0,
14
+ cacheReadTokens: 0,
15
+ outputTokens: 0,
16
+ messageCount: 0,
17
+ });
18
+
19
+ /**
20
+ * Helper: accumulates token usage from a JSONL entry into a model usage map
21
+ * @param {Object} modelUsageMap - Map of model ID to usage data
22
+ * @param {Object} entry - Parsed JSONL entry with message.usage and message.model
23
+ */
24
+ export const accumulateModelUsage = (modelUsageMap, entry) => {
25
+ const model = entry.message.model;
26
+ if (model.startsWith('<') && model.endsWith('>')) return; // Issue #1486: skip <synthetic> etc.
27
+ const usage = entry.message.usage;
28
+ if (!modelUsageMap[model]) {
29
+ modelUsageMap[model] = {
30
+ inputTokens: 0,
31
+ cacheCreationTokens: 0,
32
+ cacheCreation5mTokens: 0,
33
+ cacheCreation1hTokens: 0,
34
+ cacheReadTokens: 0,
35
+ outputTokens: 0,
36
+ webSearchRequests: 0,
37
+ };
38
+ }
39
+ if (usage.input_tokens) modelUsageMap[model].inputTokens += usage.input_tokens;
40
+ if (usage.cache_creation_input_tokens) modelUsageMap[model].cacheCreationTokens += usage.cache_creation_input_tokens;
41
+ if (usage.cache_creation) {
42
+ if (usage.cache_creation.ephemeral_5m_input_tokens) modelUsageMap[model].cacheCreation5mTokens += usage.cache_creation.ephemeral_5m_input_tokens;
43
+ if (usage.cache_creation.ephemeral_1h_input_tokens) modelUsageMap[model].cacheCreation1hTokens += usage.cache_creation.ephemeral_1h_input_tokens;
44
+ }
45
+ if (usage.cache_read_input_tokens) modelUsageMap[model].cacheReadTokens += usage.cache_read_input_tokens;
46
+ if (usage.output_tokens) modelUsageMap[model].outputTokens += usage.output_tokens;
47
+ };
48
+
49
+ /**
50
+ * Display detailed model usage information
51
+ * @param {Object} usage - Usage data for a model
52
+ * @param {Function} log - Logging function
53
+ */
54
+ export const displayModelUsage = async (usage, log) => {
55
+ // Show all model characteristics if available
56
+ if (usage.modelInfo) {
57
+ const info = usage.modelInfo;
58
+ const fields = [
59
+ { label: 'Model ID', value: info.id },
60
+ { label: 'Provider', value: info.provider || 'Unknown' },
61
+ { label: 'Context window', value: info.limit?.context ? `${formatNumber(info.limit.context)} tokens` : null },
62
+ { label: 'Max output', value: info.limit?.output ? `${formatNumber(info.limit.output)} tokens` : null },
63
+ { label: 'Input modalities', value: info.modalities?.input?.join(', ') || 'N/A' },
64
+ { label: 'Output modalities', value: info.modalities?.output?.join(', ') || 'N/A' },
65
+ { label: 'Knowledge cutoff', value: info.knowledge },
66
+ { label: 'Released', value: info.release_date },
67
+ {
68
+ label: 'Capabilities',
69
+ value: [info.attachment && 'Attachments', info.reasoning && 'Reasoning', info.temperature && 'Temperature', info.tool_call && 'Tool calls'].filter(Boolean).join(', ') || 'N/A',
70
+ },
71
+ { label: 'Open weights', value: info.open_weights ? 'Yes' : 'No' },
72
+ ];
73
+ for (const { label, value } of fields) {
74
+ if (value) await log(` ${label}: ${value}`);
75
+ }
76
+ await log('');
77
+ } else {
78
+ await log(' ⚠️ Model info not available\n');
79
+ }
80
+ // Show usage data
81
+ await log(' Usage:');
82
+ await log(` Input tokens: ${formatNumber(usage.inputTokens)}`);
83
+ if (usage.cacheCreationTokens > 0) {
84
+ await log(` Cache creation tokens: ${formatNumber(usage.cacheCreationTokens)}`);
85
+ }
86
+ if (usage.cacheReadTokens > 0) {
87
+ await log(` Cache read tokens: ${formatNumber(usage.cacheReadTokens)}`);
88
+ }
89
+ await log(` Output tokens: ${formatNumber(usage.outputTokens)}`);
90
+ if (usage.webSearchRequests > 0) {
91
+ await log(` Web search requests: ${usage.webSearchRequests}`);
92
+ }
93
+ // Show detailed cost calculation
94
+ if (usage.costUSD !== null && usage.costUSD !== undefined && usage.costBreakdown) {
95
+ await log('');
96
+ await log(' Cost Calculation (USD):');
97
+ const breakdown = usage.costBreakdown;
98
+ const types = [
99
+ { key: 'input', label: 'Input' },
100
+ { key: 'cacheWrite', label: 'Cache write' },
101
+ { key: 'cacheRead', label: 'Cache read' },
102
+ { key: 'output', label: 'Output' },
103
+ ];
104
+ for (const { key, label } of types) {
105
+ if (breakdown[key].tokens > 0) {
106
+ await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens × $${breakdown[key].costPerMillion}/M = $${breakdown[key].cost.toFixed(6)}`);
107
+ }
108
+ }
109
+ await log(' ─────────────────────────────────');
110
+ await log(` Total: $${usage.costUSD.toFixed(6)}`);
111
+ } else if (usage.modelInfo === null) {
112
+ await log('');
113
+ await log(' Cost: Not available (could not fetch pricing)');
114
+ }
115
+ };
116
+
117
+ /**
118
+ * Display cost comparison between public pricing and Anthropic's official cost
119
+ * @param {number|null} publicCost - Public pricing estimate
120
+ * @param {number|null} anthropicCost - Anthropic's official cost
121
+ * @param {Function} log - Logging function
122
+ */
123
+ export const displayCostComparison = async (publicCost, anthropicCost, log) => {
124
+ await log('\n 💰 Cost estimation:');
125
+ await log(` Public pricing estimate: ${publicCost !== null && publicCost !== undefined ? `$${publicCost.toFixed(6)} USD` : 'unknown'}`);
126
+ await log(` Calculated by Anthropic: ${anthropicCost !== null && anthropicCost !== undefined ? `$${anthropicCost.toFixed(6)} USD` : 'unknown'}`);
127
+ if (publicCost !== null && publicCost !== undefined && anthropicCost !== null && anthropicCost !== undefined) {
128
+ const difference = anthropicCost - publicCost;
129
+ const percentDiff = publicCost > 0 ? (difference / publicCost) * 100 : 0;
130
+ await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
131
+ } else {
132
+ await log(' Difference: unknown');
133
+ }
134
+ };
135
+
7
136
  /**
8
137
  * Display token budget statistics (context window usage and ratios)
9
138
  * @param {Object} usage - Usage data for a model
@@ -48,3 +177,132 @@ export const displayBudgetStats = async (usage, log) => {
48
177
  const totalSessionTokens = usage.inputTokens + usage.cacheCreationTokens + usage.outputTokens;
49
178
  await log(` Total session tokens: ${formatNumber(totalSessionTokens)}`);
50
179
  };
180
+
181
+ /**
182
+ * Display sub-session breakdown when compactification events occurred (Issue #1491)
183
+ * @param {Object} tokenUsage - Token usage data with subSessions and compactifications
184
+ * @param {Object} modelInfo - Model info with context/output limits
185
+ * @param {Function} log - Logging function
186
+ */
187
+ export const displaySubSessionStats = async (tokenUsage, modelInfo, log) => {
188
+ if (!tokenUsage.subSessions || !tokenUsage.compactifications) return;
189
+
190
+ const contextLimit = modelInfo?.limit?.context;
191
+ await log(`\n 🔄 Compactification events: ${tokenUsage.compactifications.length}`);
192
+
193
+ for (let i = 0; i < tokenUsage.subSessions.length; i++) {
194
+ const sub = tokenUsage.subSessions[i];
195
+ const totalInput = sub.inputTokens + sub.cacheCreationTokens + sub.cacheReadTokens;
196
+ const label = i === 0 ? 'Initial session' : `After compactification #${i}`;
197
+
198
+ await log(` Sub-session ${i + 1} (${label}):`);
199
+ await log(` Messages: ${sub.messageCount}`);
200
+ await log(` Context used: ${formatNumber(totalInput)} tokens`);
201
+ if (contextLimit) {
202
+ const pct = ((totalInput / contextLimit) * 100).toFixed(2);
203
+ await log(` Context usage: ${pct}% of ${formatNumber(contextLimit)}`);
204
+ }
205
+ await log(` Output: ${formatNumber(sub.outputTokens)} tokens`);
206
+ }
207
+
208
+ // Show compactification details
209
+ for (let i = 0; i < tokenUsage.compactifications.length; i++) {
210
+ const comp = tokenUsage.compactifications[i];
211
+ let detail = ` Compactification #${i + 1}: trigger=${comp.trigger}`;
212
+ if (comp.preTokens) detail += `, pre-compaction tokens=${formatNumber(comp.preTokens)}`;
213
+ await log(detail);
214
+ }
215
+ };
216
+
217
+ /**
218
+ * Display stream vs JSONL token comparison (Issue #1491)
219
+ * Shows independent calculation from stream events vs JSONL session file
220
+ * @param {Object} streamTokenUsage - Token usage accumulated from stream JSON events
221
+ * @param {Object} jsonlTokenUsage - Token usage calculated from JSONL session file
222
+ * @param {Function} log - Logging function
223
+ */
224
+ export const displayTokenComparison = async (streamTokenUsage, jsonlTokenUsage, log) => {
225
+ if (!streamTokenUsage || !jsonlTokenUsage) return;
226
+
227
+ const streamTotal = streamTokenUsage.inputTokens + streamTokenUsage.cacheCreationTokens + streamTokenUsage.outputTokens;
228
+ const jsonlTotal = jsonlTokenUsage.inputTokens + jsonlTokenUsage.cacheCreationTokens + jsonlTokenUsage.outputTokens;
229
+
230
+ await log('\n 🔍 Token calculation comparison:');
231
+ await log(` Stream JSON events: ${formatNumber(streamTotal)} tokens (${streamTokenUsage.eventCount} events)`);
232
+ await log(` JSONL session file: ${formatNumber(jsonlTotal)} tokens`);
233
+
234
+ if (streamTotal !== jsonlTotal) {
235
+ const diff = jsonlTotal - streamTotal;
236
+ const pct = streamTotal > 0 ? ((diff / streamTotal) * 100).toFixed(2) : 'N/A';
237
+ await log(` Difference: ${formatNumber(Math.abs(diff))} tokens (${diff > 0 ? '+' : ''}${pct}%)`);
238
+ } else {
239
+ await log(' Match: calculations are consistent');
240
+ }
241
+ };
242
+
243
+ /**
244
+ * Build budget stats string for GitHub PR comments (Issue #1491)
245
+ * Similar to buildCostInfoString but for token budget statistics
246
+ * @param {Object} tokenUsage - Token usage data from calculateSessionTokens
247
+ * @param {Object|null} streamTokenUsage - Token usage from stream JSON events
248
+ * @returns {string} Formatted markdown string for PR comment
249
+ */
250
+ export const buildBudgetStatsString = (tokenUsage, streamTokenUsage) => {
251
+ if (!tokenUsage) return '';
252
+
253
+ let stats = '\n\n### 📊 **Token budget statistics:**';
254
+
255
+ // Per-model breakdown
256
+ if (tokenUsage.modelUsage) {
257
+ const modelIds = Object.keys(tokenUsage.modelUsage);
258
+ for (const modelId of modelIds) {
259
+ const usage = tokenUsage.modelUsage[modelId];
260
+ const modelName = usage.modelName || modelId;
261
+ const contextLimit = usage.modelInfo?.limit?.context;
262
+ const outputLimit = usage.modelInfo?.limit?.output;
263
+ const totalInput = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens;
264
+
265
+ if (modelIds.length > 1) stats += `\n- **${modelName}**:`;
266
+
267
+ if (contextLimit) {
268
+ const contextPct = ((totalInput / contextLimit) * 100).toFixed(2);
269
+ stats += `\n- Context window: ${totalInput.toLocaleString()} / ${contextLimit.toLocaleString()} tokens (${contextPct}%)`;
270
+ } else {
271
+ stats += `\n- Context tokens used: ${totalInput.toLocaleString()}`;
272
+ }
273
+
274
+ if (outputLimit) {
275
+ const outputPct = ((usage.outputTokens / outputLimit) * 100).toFixed(2);
276
+ stats += `\n- Output tokens: ${usage.outputTokens.toLocaleString()} / ${outputLimit.toLocaleString()} tokens (${outputPct}%)`;
277
+ } else {
278
+ stats += `\n- Output tokens: ${usage.outputTokens.toLocaleString()}`;
279
+ }
280
+ }
281
+ }
282
+
283
+ // Sub-session breakdown if compactification occurred
284
+ if (tokenUsage.subSessions && tokenUsage.compactifications) {
285
+ stats += `\n- Compactifications: ${tokenUsage.compactifications.length}`;
286
+ for (let i = 0; i < tokenUsage.subSessions.length; i++) {
287
+ const sub = tokenUsage.subSessions[i];
288
+ const totalInput = sub.inputTokens + sub.cacheCreationTokens + sub.cacheReadTokens;
289
+ const label = i === 0 ? 'initial' : `after compactification #${i}`;
290
+ stats += `\n - Sub-session ${i + 1} (${label}): ${totalInput.toLocaleString()} context, ${sub.outputTokens.toLocaleString()} output, ${sub.messageCount} messages`;
291
+ }
292
+ }
293
+
294
+ // Stream vs JSONL comparison
295
+ if (streamTokenUsage) {
296
+ const streamTotal = streamTokenUsage.inputTokens + streamTokenUsage.cacheCreationTokens + streamTokenUsage.outputTokens;
297
+ const jsonlTotal = tokenUsage.inputTokens + tokenUsage.cacheCreationTokens + tokenUsage.outputTokens;
298
+ stats += `\n- Own calculation (stream): ${streamTotal.toLocaleString()} tokens (${streamTokenUsage.eventCount} events)`;
299
+ stats += `\n- JSONL calculation: ${jsonlTotal.toLocaleString()} tokens`;
300
+ if (streamTotal !== jsonlTotal) {
301
+ const diff = jsonlTotal - streamTotal;
302
+ const pct = streamTotal > 0 ? ((diff / streamTotal) * 100).toFixed(2) : 'N/A';
303
+ stats += ` (diff: ${diff > 0 ? '+' : ''}${pct}%)`;
304
+ }
305
+ }
306
+
307
+ return stats;
308
+ };
@@ -12,7 +12,7 @@ import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToToke
12
12
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
14
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
15
- import { displayBudgetStats } from './claude.budget-stats.lib.mjs';
15
+ import { displayBudgetStats, displaySubSessionStats, displayTokenComparison, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison } from './claude.budget-stats.lib.mjs';
16
16
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
17
17
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
18
18
  import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
@@ -480,91 +480,6 @@ export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) =
480
480
  }
481
481
  return totalCost;
482
482
  };
483
- /**
484
- * Display detailed model usage information
485
- * @param {Object} usage - Usage data for a model
486
- * @param {Function} log - Logging function
487
- */
488
- const displayModelUsage = async (usage, log) => {
489
- // Show all model characteristics if available
490
- if (usage.modelInfo) {
491
- const info = usage.modelInfo;
492
- const fields = [
493
- { label: 'Model ID', value: info.id },
494
- { label: 'Provider', value: info.provider || 'Unknown' },
495
- { label: 'Context window', value: info.limit?.context ? `${formatNumber(info.limit.context)} tokens` : null },
496
- { label: 'Max output', value: info.limit?.output ? `${formatNumber(info.limit.output)} tokens` : null },
497
- { label: 'Input modalities', value: info.modalities?.input?.join(', ') || 'N/A' },
498
- { label: 'Output modalities', value: info.modalities?.output?.join(', ') || 'N/A' },
499
- { label: 'Knowledge cutoff', value: info.knowledge },
500
- { label: 'Released', value: info.release_date },
501
- {
502
- label: 'Capabilities',
503
- value: [info.attachment && 'Attachments', info.reasoning && 'Reasoning', info.temperature && 'Temperature', info.tool_call && 'Tool calls'].filter(Boolean).join(', ') || 'N/A',
504
- },
505
- { label: 'Open weights', value: info.open_weights ? 'Yes' : 'No' },
506
- ];
507
- for (const { label, value } of fields) {
508
- if (value) await log(` ${label}: ${value}`);
509
- }
510
- await log('');
511
- } else {
512
- await log(' ⚠️ Model info not available\n');
513
- }
514
- // Show usage data
515
- await log(' Usage:');
516
- await log(` Input tokens: ${formatNumber(usage.inputTokens)}`);
517
- if (usage.cacheCreationTokens > 0) {
518
- await log(` Cache creation tokens: ${formatNumber(usage.cacheCreationTokens)}`);
519
- }
520
- if (usage.cacheReadTokens > 0) {
521
- await log(` Cache read tokens: ${formatNumber(usage.cacheReadTokens)}`);
522
- }
523
- await log(` Output tokens: ${formatNumber(usage.outputTokens)}`);
524
- if (usage.webSearchRequests > 0) {
525
- await log(` Web search requests: ${usage.webSearchRequests}`);
526
- }
527
- // Show detailed cost calculation
528
- if (usage.costUSD !== null && usage.costUSD !== undefined && usage.costBreakdown) {
529
- await log('');
530
- await log(' Cost Calculation (USD):');
531
- const breakdown = usage.costBreakdown;
532
- const types = [
533
- { key: 'input', label: 'Input' },
534
- { key: 'cacheWrite', label: 'Cache write' },
535
- { key: 'cacheRead', label: 'Cache read' },
536
- { key: 'output', label: 'Output' },
537
- ];
538
- for (const { key, label } of types) {
539
- if (breakdown[key].tokens > 0) {
540
- await log(` ${label}: ${formatNumber(breakdown[key].tokens)} tokens × $${breakdown[key].costPerMillion}/M = $${breakdown[key].cost.toFixed(6)}`);
541
- }
542
- }
543
- await log(' ─────────────────────────────────');
544
- await log(` Total: $${usage.costUSD.toFixed(6)}`);
545
- } else if (usage.modelInfo === null) {
546
- await log('');
547
- await log(' Cost: Not available (could not fetch pricing)');
548
- }
549
- };
550
- /**
551
- * Display cost comparison between public pricing and Anthropic's official cost
552
- * @param {number|null} publicCost - Public pricing estimate
553
- * @param {number|null} anthropicCost - Anthropic's official cost
554
- * @param {Function} log - Logging function
555
- */
556
- const displayCostComparison = async (publicCost, anthropicCost, log) => {
557
- await log('\n 💰 Cost estimation:');
558
- await log(` Public pricing estimate: ${publicCost !== null && publicCost !== undefined ? `$${publicCost.toFixed(6)} USD` : 'unknown'}`);
559
- await log(` Calculated by Anthropic: ${anthropicCost !== null && anthropicCost !== undefined ? `$${anthropicCost.toFixed(6)} USD` : 'unknown'}`);
560
- if (publicCost !== null && publicCost !== undefined && anthropicCost !== null && anthropicCost !== undefined) {
561
- const difference = anthropicCost - publicCost;
562
- const percentDiff = publicCost > 0 ? (difference / publicCost) * 100 : 0;
563
- await log(` Difference: $${difference.toFixed(6)} (${percentDiff > 0 ? '+' : ''}${percentDiff.toFixed(2)}%)`);
564
- } else {
565
- await log(' Difference: unknown');
566
- }
567
- };
568
483
  export const calculateSessionTokens = async (sessionId, tempDir) => {
569
484
  const os = (await use('os')).default;
570
485
  const homeDir = os.homedir();
@@ -582,6 +497,10 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
582
497
  }
583
498
  // Initialize per-model usage tracking
584
499
  const modelUsage = {};
500
+ // Issue #1491: Track sub-sessions between compactification events
501
+ const subSessions = [];
502
+ let currentSubSession = createEmptySubSessionUsage();
503
+ const compactifications = [];
585
504
  try {
586
505
  // Read the entire file
587
506
  const fileContent = await fs.readFile(sessionFile, 'utf8');
@@ -590,53 +509,39 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
590
509
  if (!line.trim()) continue;
591
510
  try {
592
511
  const entry = JSON.parse(line);
512
+ // Issue #1491: Detect compactification boundary events
513
+ if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
514
+ // Save current sub-session and start a new one
515
+ if (currentSubSession.messageCount > 0) {
516
+ subSessions.push(currentSubSession);
517
+ }
518
+ compactifications.push({
519
+ timestamp: entry.timestamp || null,
520
+ preTokens: entry.compactMetadata?.preTokens || null,
521
+ trigger: entry.compactMetadata?.trigger || 'unknown',
522
+ });
523
+ currentSubSession = createEmptySubSessionUsage();
524
+ continue;
525
+ }
593
526
  if (entry.message && entry.message.usage && entry.message.model) {
594
- const model = entry.message.model;
595
- if (model.startsWith('<') && model.endsWith('>')) continue; // Issue #1486: skip <synthetic> etc.
527
+ accumulateModelUsage(modelUsage, entry);
528
+ // Issue #1491: Also track per-sub-session usage
596
529
  const usage = entry.message.usage;
597
- // Initialize model entry if it doesn't exist
598
- if (!modelUsage[model]) {
599
- modelUsage[model] = {
600
- inputTokens: 0,
601
- cacheCreationTokens: 0,
602
- cacheCreation5mTokens: 0,
603
- cacheCreation1hTokens: 0,
604
- cacheReadTokens: 0,
605
- outputTokens: 0,
606
- webSearchRequests: 0,
607
- };
608
- }
609
- // Add input tokens
610
- if (usage.input_tokens) {
611
- modelUsage[model].inputTokens += usage.input_tokens;
612
- }
613
- // Add cache creation tokens (total)
614
- if (usage.cache_creation_input_tokens) {
615
- modelUsage[model].cacheCreationTokens += usage.cache_creation_input_tokens;
616
- }
617
- // Add cache creation tokens breakdown (5m and 1h)
618
- if (usage.cache_creation) {
619
- if (usage.cache_creation.ephemeral_5m_input_tokens) {
620
- modelUsage[model].cacheCreation5mTokens += usage.cache_creation.ephemeral_5m_input_tokens;
621
- }
622
- if (usage.cache_creation.ephemeral_1h_input_tokens) {
623
- modelUsage[model].cacheCreation1hTokens += usage.cache_creation.ephemeral_1h_input_tokens;
624
- }
625
- }
626
- // Add cache read tokens
627
- if (usage.cache_read_input_tokens) {
628
- modelUsage[model].cacheReadTokens += usage.cache_read_input_tokens;
629
- }
630
- // Add output tokens
631
- if (usage.output_tokens) {
632
- modelUsage[model].outputTokens += usage.output_tokens;
633
- }
530
+ if (usage.input_tokens) currentSubSession.inputTokens += usage.input_tokens;
531
+ if (usage.cache_creation_input_tokens) currentSubSession.cacheCreationTokens += usage.cache_creation_input_tokens;
532
+ if (usage.cache_read_input_tokens) currentSubSession.cacheReadTokens += usage.cache_read_input_tokens;
533
+ if (usage.output_tokens) currentSubSession.outputTokens += usage.output_tokens;
534
+ currentSubSession.messageCount++;
634
535
  }
635
536
  } catch {
636
537
  // Skip lines that aren't valid JSON
637
538
  continue;
638
539
  }
639
540
  }
541
+ // Push the final sub-session
542
+ if (currentSubSession.messageCount > 0) {
543
+ subSessions.push(currentSubSession);
544
+ }
640
545
  // If no usage data was found, return null
641
546
  if (Object.keys(modelUsage).length === 0) {
642
547
  return null;
@@ -699,6 +604,9 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
699
604
  outputTokens: totalOutputTokens,
700
605
  totalTokens,
701
606
  totalCostUSD: hasCostData ? totalCostUSD : null,
607
+ // Issue #1491: Sub-session and compactification data
608
+ subSessions: subSessions.length > 1 ? subSessions : null, // Only include if compactification occurred
609
+ compactifications: compactifications.length > 0 ? compactifications : null,
702
610
  };
703
611
  } catch (readError) {
704
612
  throw new Error(`Failed to read session file: ${readError.message}`);
@@ -832,6 +740,14 @@ export const executeClaudeCommand = async params => {
832
740
  let errorDuringExecution = false;
833
741
  let resultSummary = null;
834
742
  let resultModelUsage = null;
743
+ // Issue #1491: Track token usage from stream JSON events for independent calculation
744
+ const streamTokenUsage = {
745
+ inputTokens: 0,
746
+ cacheCreationTokens: 0,
747
+ cacheReadTokens: 0,
748
+ outputTokens: 0,
749
+ eventCount: 0,
750
+ };
835
751
  // Create interactive mode handler if enabled
836
752
  let interactiveHandler = null;
837
753
  if (argv.interactiveMode && owner && repo && prNumber) {
@@ -1054,6 +970,15 @@ export const executeClaudeCommand = async params => {
1054
970
  lastMessage = data.error || JSON.stringify(data);
1055
971
  if (lastMessage.includes('Internal server error')) isInternalServerError = true;
1056
972
  }
973
+ // Issue #1491: Track token usage from stream events for independent calculation
974
+ if (data.type === 'assistant' && data.message && data.message.usage) {
975
+ const u = data.message.usage;
976
+ if (u.input_tokens) streamTokenUsage.inputTokens += u.input_tokens;
977
+ if (u.cache_creation_input_tokens) streamTokenUsage.cacheCreationTokens += u.cache_creation_input_tokens;
978
+ if (u.cache_read_input_tokens) streamTokenUsage.cacheReadTokens += u.cache_read_input_tokens;
979
+ if (u.output_tokens) streamTokenUsage.outputTokens += u.output_tokens;
980
+ streamTokenUsage.eventCount++;
981
+ }
1057
982
  if (data.type === 'assistant' && data.message && data.message.content) {
1058
983
  const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
1059
984
  for (const item of content) {
@@ -1336,6 +1261,15 @@ export const executeClaudeCommand = async params => {
1336
1261
  await displayBudgetStats(usage, log);
1337
1262
  }
1338
1263
  }
1264
+ // Issue #1491: Display sub-session breakdown if compactification occurred
1265
+ if (argv.tokensBudgetStats && tokenUsage.subSessions) {
1266
+ const primaryModelInfo = Object.values(tokenUsage.modelUsage).find(u => u.modelInfo?.limit)?.modelInfo;
1267
+ await displaySubSessionStats(tokenUsage, primaryModelInfo, log);
1268
+ }
1269
+ // Issue #1491: Display stream vs JSONL token comparison
1270
+ if (argv.tokensBudgetStats && streamTokenUsage.eventCount > 0) {
1271
+ await displayTokenComparison(streamTokenUsage, tokenUsage, log);
1272
+ }
1339
1273
  // Show totals if multiple models were used
1340
1274
  if (modelIds.length > 1) {
1341
1275
  await log('\n 📈 Total across all models:');
@@ -1381,6 +1315,7 @@ export const executeClaudeCommand = async params => {
1381
1315
  errorDuringExecution, // Issue #1088: Track if error_during_execution subtype occurred
1382
1316
  resultSummary, // Issue #1263: Include result summary for --attach-solution-summary
1383
1317
  resultModelUsage, // Issue #1454
1318
+ streamTokenUsage: streamTokenUsage.eventCount > 0 ? streamTokenUsage : null, // Issue #1491
1384
1319
  };
1385
1320
  } catch (error) {
1386
1321
  reportError(error, {
@@ -12,8 +12,8 @@ import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
12
12
  import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: https://github.com/link-assistant/hive-mind/issues/1236
13
13
  // Import model info helpers (Issue #1225)
14
14
  import { getToolDisplayName, getModelInfoForComment } from './models/index.mjs';
15
- // Re-export for use by other modules
16
- export { getToolDisplayName };
15
+ export { getToolDisplayName }; // Re-export for use by other modules
16
+ import { buildBudgetStatsString } from './claude.budget-stats.lib.mjs';
17
17
 
18
18
  /** Build cost estimation string for log comments (Issue #1250) */
19
19
  const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
@@ -366,7 +366,9 @@ export async function attachLogToGitHub(options) {
366
366
  requestedModel = null, // Issue #1225: The --model flag value
367
367
  tool = null, // The tool used (claude, agent, opencode, codex)
368
368
  resultModelUsage = null, // Issue #1454
369
+ budgetStatsData = null, // Issue #1491: budget stats for comment
369
370
  } = options;
371
+ const budgetStats = budgetStatsData ? buildBudgetStatsString(budgetStatsData.tokenUsage, budgetStatsData.streamTokenUsage) : '';
370
372
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
371
373
  const ghCommand = targetType === 'pr' ? 'pr' : 'issue';
372
374
  try {
@@ -552,7 +554,7 @@ ${logContent}
552
554
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
553
555
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
554
556
  logComment = `## ⚠️ Solution Draft Finished with Errors
555
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${modelInfoString}
557
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
556
558
 
557
559
  > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
558
560
 
@@ -568,10 +570,8 @@ ${logContent}
568
570
  ---
569
571
  *Now working session is ended, feel free to review and add any feedback on the solution draft.*`;
570
572
  } else {
571
- // Success log format - use helper function for cost info
572
573
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
573
- // Determine title based on session type
574
- // See: https://github.com/link-assistant/hive-mind/issues/1152
574
+ // Determine title based on session type (Issue #1152)
575
575
  let title = customTitle;
576
576
  let sessionNote = '';
577
577
  if (sessionType === 'auto-resume') {
@@ -585,7 +585,7 @@ ${logContent}
585
585
  sessionNote = '\n\n**Note**: This session was manually resumed using the --resume flag.';
586
586
  }
587
587
  logComment = `## ${title}
588
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${modelInfoString}${sessionNote}
588
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}${sessionNote}
589
589
 
590
590
  <details>
591
591
  <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
@@ -733,7 +733,7 @@ ${errorMessage}
733
733
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
734
734
  const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
735
735
  logUploadComment = `## ⚠️ Solution Draft Finished with Errors
736
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${modelInfoString}
736
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
737
737
 
738
738
  > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
739
739
 
@@ -760,7 +760,7 @@ This log file contains the complete execution trace of the AI ${targetType === '
760
760
  sessionNote = '\n**Note**: This session was manually resumed using the --resume flag.\n';
761
761
  }
762
762
  logUploadComment = `## ${title}
763
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${modelInfoString}
763
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
764
764
  ${sessionNote}
765
765
  ### 📎 **Log file uploaded as ${uploadTypeLabel}${chunkInfo}** (${Math.round(logStats.size / 1024)}KB)
766
766
  - [View complete solution draft log](${logUrl})
package/src/solve.mjs CHANGED
@@ -876,9 +876,10 @@ try {
876
876
  let anthropicTotalCostUSD = toolResult.anthropicTotalCostUSD;
877
877
  let publicPricingEstimate = toolResult.publicPricingEstimate; // Used by agent tool
878
878
  let pricingInfo = toolResult.pricingInfo; // Used by agent tool for detailed pricing
879
- let errorDuringExecution = toolResult.errorDuringExecution || false; // Issue #1088: Track error_during_execution
880
- let resultSummary = toolResult.resultSummary || null; // Issue #1263: Capture result summary for --attach-solution-summary
881
- let resultModelUsage = toolResult.resultModelUsage || null; // Issue #1454: Capture modelUsage from result JSON
879
+ let errorDuringExecution = toolResult.errorDuringExecution || false;
880
+ let resultSummary = toolResult.resultSummary || null;
881
+ let resultModelUsage = toolResult.resultModelUsage || null;
882
+ let streamTokenUsage = toolResult.streamTokenUsage || null;
882
883
  limitReached = toolResult.limitReached;
883
884
  cleanupContext.limitReached = limitReached;
884
885
 
@@ -1216,7 +1217,7 @@ try {
1216
1217
  }
1217
1218
 
1218
1219
  // Search for newly created pull requests and comments
1219
- const verifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage);
1220
+ const verifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage);
1220
1221
  const logsAlreadyUploaded = verifyResult?.logUploadSuccess || false;
1221
1222
 
1222
1223
  // Issue #1162: Auto-restart when PR title/description still has placeholder content
@@ -1263,7 +1264,7 @@ try {
1263
1264
  await cleanupClaudeFile(tempDir, branchName, null, argv);
1264
1265
 
1265
1266
  // Re-verify results after restart (without auto-restart flag to prevent recursion)
1266
- const reVerifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, { ...argv, autoRestartOnNonUpdatedPullRequestDescription: false }, shouldAttachLogs, false, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage);
1267
+ const reVerifyResult = await verifyResults(owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, { ...argv, autoRestartOnNonUpdatedPullRequestDescription: false }, shouldAttachLogs, false, sessionId, tempDir, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo, errorDuringExecution, sessionType, resultModelUsage, streamTokenUsage);
1267
1268
 
1268
1269
  if (reVerifyResult?.prTitleHasPlaceholder || reVerifyResult?.prBodyHasPlaceholder) {
1269
1270
  await log('⚠️ PR title/description still not updated after restart');
@@ -1492,9 +1493,6 @@ try {
1492
1493
  // drainHandles() inside safeExit() will unref/close these before process.exit().
1493
1494
  await logActiveHandles(msg => log(msg));
1494
1495
 
1495
- // Issue #1431: safeExit() calls drainHandles() to unref/close known handle types
1496
- // (process.stdin ReadStream, undici Socket pool, command-stream ChildProcess,
1497
- // process.stdout/stderr WriteStreams) so the event loop exits naturally, then
1498
- // calls process.exit(0) as a deterministic safety net.
1496
+ // Issue #1431: safeExit() unrefs handles so the event loop exits naturally, then calls process.exit(0)
1499
1497
  await safeExit(0, 'Process completed');
1500
1498
  }
@@ -494,9 +494,23 @@ export const showSessionSummary = async (sessionId, limitReached, argv, issueUrl
494
494
  };
495
495
 
496
496
  // Verify results by searching for new PRs and comments
497
- export const verifyResults = async (owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart = false, sessionId = null, tempDir = null, anthropicTotalCostUSD = null, publicPricingEstimate = null, pricingInfo = null, errorDuringExecution = false, sessionType = 'new', resultModelUsage = null) => {
497
+ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumber, prUrl, referenceTime, argv, shouldAttachLogs, shouldRestart = false, sessionId = null, tempDir = null, anthropicTotalCostUSD = null, publicPricingEstimate = null, pricingInfo = null, errorDuringExecution = false, sessionType = 'new', resultModelUsage = null, streamTokenUsage = null) => {
498
498
  await log('\n🔍 Searching for created pull requests or comments...');
499
499
 
500
+ // Issue #1491: Build budget stats data for GitHub comment (computed once, used in both PR and issue paths)
501
+ let budgetStatsData = null;
502
+ if (argv.tokensBudgetStats && sessionId && tempDir) {
503
+ try {
504
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
505
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
506
+ if (tokenUsage) {
507
+ budgetStatsData = { tokenUsage, streamTokenUsage };
508
+ }
509
+ } catch (budgetError) {
510
+ if (argv.verbose) await log(` ⚠️ Could not calculate budget stats: ${budgetError.message}`, { verbose: true });
511
+ }
512
+ }
513
+
500
514
  try {
501
515
  // Get the current user's GitHub username
502
516
  const userResult = await $`gh api user --jq .login`;
@@ -713,6 +727,8 @@ Fixes ${issueRef}
713
727
  tool: argv.tool || 'claude',
714
728
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
715
729
  resultModelUsage,
730
+ // Issue #1491: Pass budget stats for token budget display in comment
731
+ budgetStatsData,
716
732
  });
717
733
  }
718
734
 
@@ -797,6 +813,8 @@ Fixes ${issueRef}
797
813
  tool: argv.tool || 'claude',
798
814
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
799
815
  resultModelUsage,
816
+ // Issue #1491: Pass budget stats for token budget display in comment
817
+ budgetStatsData,
800
818
  });
801
819
  }
802
820
 
@@ -558,25 +558,26 @@ function validateGitHubUrl(args, options = {}) {
558
558
  return { valid: true, parsed, normalizedUrl: url };
559
559
  }
560
560
 
561
- /**
562
- * Escape special characters for Telegram's legacy Markdown parser.
563
- * In Telegram's Markdown, these characters need escaping: _ * [ ] ( ) ~ ` > # + - = | { } . !
564
- * However, for plain text (not inside markup), we primarily need to escape _ and *
565
- * to prevent them from being interpreted as formatting.
566
- *
567
- * @param {string} text - Text to escape
568
- * @returns {string} Escaped text safe for Markdown parse_mode
569
- */
570
- /**
571
- * Execute a start-screen command and update the initial message with the result.
572
- * Used by both /solve and /hive commands to reduce code duplication.
573
- *
574
- * @param {Object} ctx - Telegram context
575
- * @param {Object} startingMessage - The initial message to update
576
- * @param {string} commandName - Command name (e.g., 'solve' or 'hive')
577
- * @param {string[]} args - Command arguments
578
- * @param {string} infoBlock - Info block with request details
579
- */
561
+ // Issue #1460/#1497: safeReply - try Markdown first, fall back to plain text on parsing errors
562
+ async function safeReply(ctx, text, options = {}) {
563
+ try {
564
+ return await ctx.reply(text, { parse_mode: 'Markdown', ...options });
565
+ } catch (error) {
566
+ const isParsingError = error.message && (error.message.includes("can't parse entities") || error.message.includes("Can't parse entities") || error.message.includes("can't find end of") || (error.message.includes('Bad Request') && error.message.includes('400')));
567
+ if (!isParsingError) throw error;
568
+ console.error(`[telegram-bot] safeReply: Markdown parsing failed: ${error.message}`);
569
+ console.error(`[telegram-bot] safeReply: Failing message (${Buffer.byteLength(text, 'utf-8')} bytes): ${text}`);
570
+ const plainText = text
571
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
572
+ .replace(/\\_/g, '_')
573
+ .replace(/\\\*/g, '*')
574
+ .replace(/\*([^*]+)\*/g, '$1')
575
+ .replace(/`([^`]+)`/g, '$1');
576
+ return await ctx.reply(plainText, { ...options, parse_mode: undefined });
577
+ }
578
+ }
579
+
580
+ // Execute a start-screen command and update the initial message with the result
580
581
  async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock) {
581
582
  const result = await executeStartScreen(commandName, args);
582
583
  const { chat, message_id } = startingMessage;
@@ -914,8 +915,7 @@ async function handleSolveCommand(ctx) {
914
915
  if (VERBOSE) {
915
916
  console.log('[VERBOSE] Multiple GitHub URLs found in replied message');
916
917
  }
917
- await ctx.reply(`❌ ${extraction.error}`, {
918
- parse_mode: 'Markdown',
918
+ await safeReply(ctx, `❌ ${escapeMarkdown(extraction.error)}`, {
919
919
  reply_to_message_id: ctx.message.message_id,
920
920
  });
921
921
  return;
@@ -931,7 +931,7 @@ async function handleSolveCommand(ctx) {
931
931
  if (VERBOSE) {
932
932
  console.log('[VERBOSE] No GitHub URL found in replied message');
933
933
  }
934
- await ctx.reply('❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`\n\nOr with options: `/solve --model opus`', { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
934
+ await safeReply(ctx, '❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`\n\nOr with options: `/solve --model opus`', { reply_to_message_id: ctx.message.message_id });
935
935
  return;
936
936
  }
937
937
  }
@@ -943,7 +943,7 @@ async function handleSolveCommand(ctx) {
943
943
  errorMsg += `\n\n💡 Did you mean: \`${validation.suggestion}\``;
944
944
  }
945
945
  errorMsg += '\n\nExample: `/solve https://github.com/owner/repo/issues/123`\n\nOr reply to a message containing a GitHub link with `/solve`';
946
- await ctx.reply(errorMsg, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
946
+ await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
947
947
  return;
948
948
  }
949
949
 
@@ -963,19 +963,19 @@ async function handleSolveCommand(ctx) {
963
963
  // Validate model name with helpful error message (before yargs validation)
964
964
  const modelError = validateModelInArgs(args, solveTool);
965
965
  if (modelError) {
966
- await ctx.reply(`❌ ${modelError}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
966
+ await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });
967
967
  return;
968
968
  }
969
969
  // Issue #1482: Validate --base-branch early to reject URLs and invalid branch names
970
970
  const branchError = validateBranchInArgs(args);
971
971
  if (branchError) {
972
- await ctx.reply(`❌ ${branchError}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
972
+ await safeReply(ctx, `❌ ${escapeMarkdown(branchError)}`, { reply_to_message_id: ctx.message.message_id });
973
973
  return;
974
974
  }
975
975
  // Issue #1092: Detect malformed flag patterns like "-- model" (space after --)
976
976
  const { malformed, errors: malformedErrors } = detectMalformedFlags(args);
977
977
  if (malformed.length > 0) {
978
- await ctx.reply(`❌ ${malformedErrors.join('\n')}\n\nPlease check your option syntax.`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
978
+ await safeReply(ctx, `❌ ${escapeMarkdown(malformedErrors.join('\n'))}\n\nPlease check your option syntax.`, { reply_to_message_id: ctx.message.message_id });
979
979
  return;
980
980
  }
981
981
  // Validate merged arguments using solve's yargs config
@@ -994,8 +994,7 @@ async function handleSolveCommand(ctx) {
994
994
 
995
995
  testYargs.parse(args);
996
996
  } catch (error) {
997
- await ctx.reply(`❌ Invalid options: ${error.message || String(error)}\n\nUse /help to see available options`, {
998
- parse_mode: 'Markdown',
997
+ await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, {
999
998
  reply_to_message_id: ctx.message.message_id,
1000
999
  });
1001
1000
  return;
@@ -1019,7 +1018,7 @@ async function handleSolveCommand(ctx) {
1019
1018
  const existingItem = solveQueue.findByUrl(normalizedUrl);
1020
1019
  if (existingItem) {
1021
1020
  const statusText = existingItem.status === 'starting' || existingItem.status === 'started' ? 'being processed' : 'already in the queue';
1022
- await ctx.reply(`❌ This URL is ${statusText}.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nStatus: ${existingItem.status}\n\n💡 Use /solve_queue to check the queue status.`, { parse_mode: 'Markdown', 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 });
1023
1022
  return;
1024
1023
  }
1025
1024
 
@@ -1031,18 +1030,18 @@ async function handleSolveCommand(ctx) {
1031
1030
  // their command cannot be processed (e.g., disk full, server maintenance pending).
1032
1031
  // See: https://github.com/link-assistant/hive-mind/issues/1267
1033
1032
  if (check.rejected) {
1034
- await ctx.reply(`❌ Solve command rejected.\n\n${infoBlock}\n\n🚫 Reason: ${check.rejectReason}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1033
+ await safeReply(ctx, `❌ Solve command rejected.\n\n${infoBlock}\n\n🚫 Reason: ${escapeMarkdown(check.rejectReason || 'Unknown')}`, { reply_to_message_id: ctx.message.message_id });
1035
1034
  return;
1036
1035
  }
1037
1036
 
1038
1037
  if (check.canStart && queueStats.queued === 0) {
1039
- const startingMessage = await ctx.reply(`🚀 Starting solve command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1038
+ const startingMessage = await safeReply(ctx, `🚀 Starting solve command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1040
1039
  await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
1041
1040
  } else {
1042
1041
  const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool });
1043
1042
  let queueMessage = `📋 Solve command queued (position #${queueStats.queued + 1})\n\n${infoBlock}`;
1044
- if (check.reason) queueMessage += `\n\n⏳ Waiting: ${check.reason}`;
1045
- const queuedMessage = await ctx.reply(queueMessage, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1043
+ if (check.reason) queueMessage += `\n\n⏳ Waiting: ${escapeMarkdown(check.reason)}`;
1044
+ const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
1046
1045
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
1047
1046
  if (!solveQueue.executeCallback) solveQueue.executeCallback = createQueueExecuteCallback(executeStartScreen);
1048
1047
  }
@@ -1122,7 +1121,7 @@ async function handleHiveCommand(ctx) {
1122
1121
  let errorMsg = `❌ ${validation.error}`;
1123
1122
  if (validation.suggestion) errorMsg += `\n\n💡 Did you mean: \`${escapeMarkdown(validation.suggestion)}\``;
1124
1123
  errorMsg += '\n\nExample: `/hive https://github.com/owner/repo`';
1125
- await ctx.reply(errorMsg, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1124
+ await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
1126
1125
  return;
1127
1126
  }
1128
1127
  // Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
@@ -1149,13 +1148,13 @@ async function handleHiveCommand(ctx) {
1149
1148
  // Validate model name with helpful error message (before yargs validation)
1150
1149
  const hiveModelError = validateModelInArgs(args, hiveTool);
1151
1150
  if (hiveModelError) {
1152
- await ctx.reply(`❌ ${hiveModelError}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1151
+ await safeReply(ctx, `❌ ${escapeMarkdown(hiveModelError)}`, { reply_to_message_id: ctx.message.message_id });
1153
1152
  return;
1154
1153
  }
1155
1154
  // Issue #1482: Validate branch flags early to reject URLs and invalid branch names
1156
1155
  const hiveBranchError = validateBranchInArgs(args);
1157
1156
  if (hiveBranchError) {
1158
- await ctx.reply(`❌ ${hiveBranchError}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1157
+ await safeReply(ctx, `❌ ${escapeMarkdown(hiveBranchError)}`, { reply_to_message_id: ctx.message.message_id });
1159
1158
  return;
1160
1159
  }
1161
1160
 
@@ -1175,8 +1174,7 @@ async function handleHiveCommand(ctx) {
1175
1174
 
1176
1175
  testYargs.parse(args);
1177
1176
  } catch (error) {
1178
- await ctx.reply(`❌ Invalid options: ${error.message || String(error)}\n\nUse /help to see available options`, {
1179
- parse_mode: 'Markdown',
1177
+ await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, {
1180
1178
  reply_to_message_id: ctx.message.message_id,
1181
1179
  });
1182
1180
  return;
@@ -1193,7 +1191,7 @@ async function handleHiveCommand(ctx) {
1193
1191
  infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}🔒 Locked options: ${escapeMarkdown(hiveOverrides.join(' '))}`;
1194
1192
  }
1195
1193
 
1196
- const startingMessage = await ctx.reply(`🚀 Starting hive command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1194
+ const startingMessage = await safeReply(ctx, `🚀 Starting hive command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1197
1195
  await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock);
1198
1196
  }
1199
1197