@link-assistant/hive-mind 1.73.5 → 1.73.7

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,52 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.73.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 6188172: feat(telegram): list executed issues/PRs as clickable links in /solve_queue, add /queue alias (#1837)
8
+
9
+ The `/solve_queue` detailed status previously showed only per-tool counts and a
10
+ final `Completed: N, Failed: M` line, so a stuck or running task could not be
11
+ opened from the message. It now lists each processing (`▶️`), pending (`•`),
12
+ recently completed (`✅`), and failed (`❌`, with the error reason) item as a
13
+ clickable `[owner/repo#number](url)` link, capped per section
14
+ (`HIVE_MIND_MAX_DISPLAY_ITEMS_PER_QUEUE`, default 5) with a localized
15
+ `... and N more` line to stay under Telegram's 4096-character limit.
16
+
17
+ Also adds `/queue` as a shorter alias for `/solve_queue` (both the entity-based
18
+ command regex and the text-based fallback handler), and documents the work in
19
+ `docs/case-studies/issue-1837`.
20
+
21
+ ## 1.73.6
22
+
23
+ ### Patch Changes
24
+
25
+ - defa8c4: fix(claude): repair corrupted thinking-block transcripts so resume preserves context (#1834)
26
+
27
+ Follow-up to the Issue #1834 recovery ("can we do even better?"). The previous
28
+ recovery (PR #1835) was reactive: a plain resume of a transcript poisoned by a
29
+ corrupted extended-thinking block (`{ "type": "thinking", "thinking": "" }` with a
30
+ kept signature) just repeats the `400 ... thinking blocks ... cannot be modified`
31
+ error, so recovery almost always fell through to a **fresh restart that discards
32
+ dozens of turns** of accumulated context (50 turns / $3.84 in the second
33
+ reproduction log).
34
+
35
+ Recovery Phase 1 now **proactively repairs the on-disk session transcript** before
36
+ resuming: `repairCorruptedThinkingBlocks` (new
37
+ `src/claude.session-transcript-repair.lib.mjs`) strips the empty-text
38
+ `thinking`/`redacted_thinking` blocks from the session JSONL — a workaround proven
39
+ upstream (the Anthropic API permits _omitting_ earlier thinking, just not
40
+ _modifying_ it). When repair succeeds the resume keeps all accumulated context;
41
+ when it can't help, recovery still falls back to a fresh restart, so there is no
42
+ regression.
43
+
44
+ The repair is conservative: it never throws, only removes empty-text blocks (valid
45
+ signed thinking is untouched), never empties an assistant message, and writes a
46
+ one-time `<session>.jsonl.pre-repair-backup` before rewriting. The case study under
47
+ `docs/case-studies/issue-1834` is updated with a second reproduction log and the
48
+ new repair-then-resume design.
49
+
3
50
  ## 1.73.5
4
51
 
5
52
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.73.5",
3
+ "version": "1.73.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Issue #1834 (PR #1836): repair a Claude Code session transcript that was poisoned by a
4
+ // corrupted extended-thinking block, so the session can be RESUMED (context preserved) instead
5
+ // of being discarded entirely.
6
+ //
7
+ // Root cause (upstream anthropics/claude-code#63147, #46843, #24662, #41992): when extended
8
+ // thinking is combined with tool use, Claude Code can persist a thinking block to the on-disk
9
+ // session JSONL with its `thinking` text emptied to "" while keeping the original `signature`:
10
+ //
11
+ // { "type": "thinking", "thinking": "", "signature": "Eyc…" }
12
+ //
13
+ // On resume/continue the API replays that block and validates the signature against the now-empty
14
+ // text, rejecting every following turn with a 400:
15
+ // `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified.
16
+ //
17
+ // The proven community workaround (anthropics/claude-code#46843, miteshashar/claude-code-thinking-
18
+ // blocks-fix) is to STRIP the corrupted (empty-text) thinking blocks from the transcript — the API
19
+ // permits omitting earlier-turn thinking, so once the offending blocks are gone the session resumes
20
+ // cleanly with all of its text/tool-use history intact. This is strictly better than throwing the
21
+ // whole session away: when the repair succeeds we keep the accumulated context (worth many dollars
22
+ // and dozens of turns); when it can't help we still fall back to a fresh restart.
23
+
24
+ import { promises as fs } from 'fs';
25
+ import os from 'os';
26
+ import path from 'path';
27
+
28
+ /**
29
+ * Resolve the on-disk session transcript path for a Claude Code session. Claude Code stores each
30
+ * session as `~/.claude/projects/<cwd-with-slashes-as-dashes>/<sessionId>.jsonl` (mirrors the
31
+ * path logic already used by getModelUsageFromSession in claude.lib.mjs).
32
+ *
33
+ * @param {string} tempDir - the working directory the Claude session ran in.
34
+ * @param {string} sessionId - the Claude Code session id.
35
+ * @param {string} [homeDir] - override home dir (tests).
36
+ * @returns {string} absolute path to the session JSONL file.
37
+ */
38
+ export const resolveSessionTranscriptPath = (tempDir, sessionId, homeDir = os.homedir()) => {
39
+ const projectDirName = String(tempDir).replace(/\//g, '-');
40
+ return path.join(homeDir, '.claude', 'projects', projectDirName, `${sessionId}.jsonl`);
41
+ };
42
+
43
+ /**
44
+ * True when a content block is a corrupted thinking block: an extended-thinking block whose text
45
+ * was emptied (the upstream corruption) — `{ type: 'thinking', thinking: '' }` (optionally with a
46
+ * stale `signature`) or the redacted variant `{ type: 'redacted_thinking', data: '' }`.
47
+ */
48
+ const isCorruptedThinkingBlock = block => {
49
+ if (!block || typeof block !== 'object') return false;
50
+ if (block.type === 'thinking') return !block.thinking; // '' / undefined / null
51
+ if (block.type === 'redacted_thinking') return !block.data;
52
+ return false;
53
+ };
54
+
55
+ /**
56
+ * Strip corrupted (empty-text) thinking blocks from a Claude Code session transcript so the session
57
+ * can be resumed. Conservative and side-effect-safe:
58
+ * - never throws (returns a result object describing what happened);
59
+ * - only removes blocks whose thinking text is empty (legitimate signed thinking is untouched);
60
+ * - never empties an assistant message (if removing the blocks would leave a message with no
61
+ * content, that message is left exactly as-is);
62
+ * - writes a one-time backup (`<file>.pre-repair-backup`) before modifying the transcript.
63
+ *
64
+ * @param {object} opts
65
+ * @param {string} opts.tempDir - working directory the session ran in.
66
+ * @param {string} opts.sessionId - Claude Code session id.
67
+ * @param {string} [opts.homeDir] - override home dir (tests).
68
+ * @param {Function} [opts.log] - async logger.
69
+ * @returns {Promise<{ repaired: boolean, removedBlocks: number, scannedLines: number, sessionFile: string|null, reason?: string }>}
70
+ */
71
+ export const repairCorruptedThinkingBlocks = async ({ tempDir, sessionId, homeDir, log = async () => {} } = {}) => {
72
+ const result = { repaired: false, removedBlocks: 0, scannedLines: 0, sessionFile: null };
73
+ if (!tempDir || !sessionId) {
74
+ return { ...result, reason: 'missing tempDir or sessionId' };
75
+ }
76
+ const sessionFile = resolveSessionTranscriptPath(tempDir, sessionId, homeDir);
77
+ result.sessionFile = sessionFile;
78
+ let fileContent;
79
+ try {
80
+ fileContent = await fs.readFile(sessionFile, 'utf8');
81
+ } catch {
82
+ // No transcript on disk (e.g. fresh run never persisted, or path mismatch) — nothing to repair.
83
+ return { ...result, reason: 'session transcript not found' };
84
+ }
85
+
86
+ try {
87
+ const lines = fileContent.split('\n');
88
+ const out = [];
89
+ let removedBlocks = 0;
90
+ let scannedLines = 0;
91
+ for (const line of lines) {
92
+ if (!line.trim()) {
93
+ out.push(line);
94
+ continue;
95
+ }
96
+ scannedLines++;
97
+ let entry;
98
+ try {
99
+ entry = JSON.parse(line);
100
+ } catch {
101
+ out.push(line); // preserve anything we can't parse verbatim
102
+ continue;
103
+ }
104
+ const content = entry?.message?.content;
105
+ if (Array.isArray(content)) {
106
+ const corrupted = content.filter(isCorruptedThinkingBlock).length;
107
+ if (corrupted > 0) {
108
+ const cleaned = content.filter(b => !isCorruptedThinkingBlock(b));
109
+ // Never leave an assistant message with an empty content array (invalid for the API).
110
+ if (cleaned.length > 0) {
111
+ entry.message.content = cleaned;
112
+ removedBlocks += corrupted;
113
+ out.push(JSON.stringify(entry));
114
+ continue;
115
+ }
116
+ }
117
+ }
118
+ out.push(line);
119
+ }
120
+
121
+ result.scannedLines = scannedLines;
122
+ if (removedBlocks === 0) {
123
+ return { ...result, reason: 'no corrupted thinking blocks found' };
124
+ }
125
+
126
+ // Back up the original transcript exactly once before rewriting it.
127
+ const backupFile = `${sessionFile}.pre-repair-backup`;
128
+ try {
129
+ await fs.access(backupFile);
130
+ } catch {
131
+ try {
132
+ await fs.copyFile(sessionFile, backupFile);
133
+ } catch {
134
+ // Best effort — a missing backup must not block the repair.
135
+ }
136
+ }
137
+
138
+ await fs.writeFile(sessionFile, out.join('\n'), 'utf8');
139
+ result.repaired = true;
140
+ result.removedBlocks = removedBlocks;
141
+ await log(`🩹 Repaired session transcript: stripped ${removedBlocks} corrupted thinking block(s) from ${scannedLines} message line(s) (Issue #1834). Backup: ${backupFile}`, { verbose: true });
142
+ return result;
143
+ } catch (error) {
144
+ // Defensive: any unexpected failure degrades gracefully to "no repair" so the caller can fall
145
+ // back to a fresh restart.
146
+ return { ...result, reason: `repair failed: ${error?.message || error}` };
147
+ }
148
+ };
149
+
150
+ export default { repairCorruptedThinkingBlocks, resolveSessionTranscriptPath };
@@ -12,9 +12,12 @@
12
12
  //
13
13
  // PR #1835 feedback: "in case of this specific error we should try resume first, and if not possible
14
14
  // try to restart." Recovery is therefore a two-phase escalation:
15
- // Phase 1 — resume the existing session (context-preserving; occasionally the transcript is intact
16
- // enough to continue).
17
- // Phase 2resume unavailable or already failed discard the session and start fresh (`/clear`).
15
+ // Phase 1 — REPAIR the on-disk transcript (strip the corrupted empty-text thinking blocks) and
16
+ // resume the existing session (context-preserving). Plain resume of a poisoned
17
+ // transcript is futile the 400 just repeats so we first remove the offending blocks,
18
+ // which the API permits omitting. When repair succeeds the resume keeps all accumulated
19
+ // text/tool-use history (Issue #1834 "can we do even better?").
20
+ // Phase 2 — repair/resume unavailable or already failed → discard the session and start fresh.
18
21
  // On every attempt we first auto-commit any uncommitted work (Issue #1834 / PR #1835 feedback:
19
22
  // "on all critical errors we auto commit uncommitted changes by default") so nothing is lost when
20
23
  // the session context resets.
@@ -22,6 +25,7 @@
22
25
  import { retryLimits, criticalErrorRecovery } from './config.lib.mjs';
23
26
  import { waitWithCountdown } from './tool-retry.lib.mjs';
24
27
  import { commitUncommittedChangesOnCriticalError } from './critical-error-commit.lib.mjs';
28
+ import { repairCorruptedThinkingBlocks } from './claude.session-transcript-repair.lib.mjs';
25
29
 
26
30
  /**
27
31
  * Create a stateful corrupted-thinking-block recovery handler. The returned function persists its
@@ -36,11 +40,13 @@ import { commitUncommittedChangesOnCriticalError } from './critical-error-commit
36
40
  * @param {Function} ctx.$ - command-stream executor.
37
41
  * @param {Function} ctx.log - async logger.
38
42
  * @param {number} [ctx.waitMs=5000] - settle delay before re-running (overridable for tests).
43
+ * @param {Function} [ctx.repair=repairCorruptedThinkingBlocks] - transcript repair (injectable for tests).
44
+ * @param {string} [ctx.homeDir] - override home dir for transcript lookup (tests).
39
45
  * @returns {(opts: {classified: object, source: string, sessionId: string|null}) => Promise<boolean>}
40
46
  * Resolves true when a recovery attempt was initiated (caller should re-run); false when
41
47
  * both caps are exhausted (caller should fail).
42
48
  */
43
- export const createThinkingBlockRecovery = ({ argv, tempDir, branchName, $, log, waitMs = 5000 }) => {
49
+ export const createThinkingBlockRecovery = ({ argv, tempDir, branchName, $, log, waitMs = 5000, repair = repairCorruptedThinkingBlocks, homeDir }) => {
44
50
  let resumeCount = 0;
45
51
  let restartCount = 0;
46
52
  return async ({ classified, source, sessionId }) => {
@@ -49,11 +55,22 @@ export const createThinkingBlockRecovery = ({ argv, tempDir, branchName, $, log,
49
55
  await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: `${classified.label} (${source})` });
50
56
  }
51
57
  };
52
- // Phase 1 — resume the existing session first (cheaper, keeps accumulated context).
58
+ // Phase 1 — repair the on-disk transcript, then resume (keeps accumulated context).
53
59
  if (sessionId && resumeCount < retryLimits.maxThinkingBlockResumes) {
54
60
  resumeCount++;
55
61
  await preserveWork();
56
- await log(`\n⚠️ ${classified.label} (${source}). Resume attempt ${resumeCount}/${retryLimits.maxThinkingBlockResumes} — trying to resume the existing session first before discarding it (Issue #1834)...`, { level: 'warning' });
62
+ await log(`\n⚠️ ${classified.label} (${source}). Resume attempt ${resumeCount}/${retryLimits.maxThinkingBlockResumes} — repairing the corrupted transcript then resuming the existing session before discarding it (Issue #1834)...`, { level: 'warning' });
63
+ // Strip the corrupted (empty-text) thinking blocks so resume isn't doomed to repeat the 400.
64
+ try {
65
+ const repairResult = await repair({ tempDir, sessionId, homeDir, log });
66
+ if (repairResult?.repaired) {
67
+ await log(` 🩹 Stripped ${repairResult.removedBlocks} corrupted thinking block(s) from the transcript — resume will preserve context (Issue #1834).`, { verbose: true });
68
+ } else {
69
+ await log(` ℹ️ Transcript repair made no change (${repairResult?.reason || 'unknown'}) — resuming as-is (Issue #1834).`, { verbose: true });
70
+ }
71
+ } catch {
72
+ // Repair must never block recovery — fall through to a plain resume attempt.
73
+ }
57
74
  argv.resume = sessionId;
58
75
  await waitWithCountdown(waitMs, log);
59
76
  await log('\n🔄 Resuming the session now...');
@@ -515,7 +515,7 @@ en
515
515
  detail "Tool aliases imply `--tool <tool>`: `/codex <github-url>` equals `/solve <github-url> --tool codex`"
516
516
  reply "Or reply to a message with a GitHub link: `/solve`"
517
517
  disabled "*/solve* (aliases: */do*, */continue*, */claude*, */codex*, */opencode*, */agent*, */gemini*, */qwen*) - ❌ Disabled"
518
- queue "`/solve_queue` - Show solve queue status"
518
+ queue "`/solve_queue` (alias: `/queue`) - Show solve queue status"
519
519
  locked
520
520
  options "🔒 Locked options: `{{options}}`"
521
521
  task
@@ -515,7 +515,7 @@ hi
515
515
  detail "Tool aliases `--tool <tool>` लगाते हैं: `/codex <github-url>` का अर्थ `/solve <github-url> --tool codex` है"
516
516
  reply "या GitHub लिंक वाले संदेश का उत्तर दें: `/solve`"
517
517
  disabled "*/solve* (aliases: */do*, */continue*, */claude*, */codex*, */opencode*, */agent*, */gemini*, */qwen*) - ❌ अक्षम"
518
- queue "`/solve_queue` - solve queue status दिखाएँ"
518
+ queue "`/solve_queue` (उपनाम: `/queue`) - solve queue status दिखाएँ"
519
519
  locked
520
520
  options "🔒 लॉक किए गए विकल्प: `{{options}}`"
521
521
  task
@@ -515,7 +515,7 @@ ru
515
515
  detail "Алиасы инструментов добавляют `--tool <tool>`: `/codex <github-url>` равно `/solve <github-url> --tool codex`"
516
516
  reply "Или ответьте на сообщение со ссылкой GitHub: `/solve`"
517
517
  disabled "*/solve* (алиасы: */do*, */continue*, */claude*, */codex*, */opencode*, */agent*, */gemini*, */qwen*) - ❌ Отключено"
518
- queue "`/solve_queue` - Показать состояние очереди solve"
518
+ queue "`/solve_queue` (псевдоним: `/queue`) - Показать состояние очереди solve"
519
519
  locked
520
520
  options "🔒 Заблокированные опции: `{{options}}`"
521
521
  task
@@ -515,7 +515,7 @@ zh
515
515
  detail "工具别名会添加 `--tool <tool>`:`/codex <github-url>` 等同于 `/solve <github-url> --tool codex`"
516
516
  reply "也可以回复包含 GitHub 链接的消息:`/solve`"
517
517
  disabled "*/solve*(别名:*/do*、*/continue*、*/claude*、*/codex*、*/opencode*、*/agent*、*/gemini*、*/qwen*)- ❌ 已禁用"
518
- queue "`/solve_queue` - 显示 solve 队列状态"
518
+ queue "`/solve_queue`(别名:`/queue`)- 显示 solve 队列状态"
519
519
  locked
520
520
  options "🔒 锁定选项:`{{options}}`"
521
521
  task
@@ -280,6 +280,13 @@ export const QUEUE_CONFIG = {
280
280
 
281
281
  // Process detection
282
282
  CLAUDE_PROCESS_NAMES: ['claude'], // Process names to detect
283
+
284
+ // Display
285
+ // Maximum number of items shown per section (pending/processing/completed/failed)
286
+ // in the /solve_queue (/queue) detailed status before collapsing into a
287
+ // "... and N more" line. Keeps the Telegram message under the 4096-char cap.
288
+ // See: https://github.com/link-assistant/hive-mind/issues/1837
289
+ MAX_DISPLAY_ITEMS_PER_QUEUE: parseIntWithDefault('HIVE_MIND_MAX_DISPLAY_ITEMS_PER_QUEUE', 5),
283
290
  };
284
291
 
285
292
  /**
@@ -1169,7 +1169,8 @@ bot.on('message', async (ctx, next) => {
1169
1169
  // /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback — Telegraf's bot.command() is sufficient.
1170
1170
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1171
1171
  const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
1172
- const handlers = { ...solveHandlers, ...taskHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand };
1172
+ // /queue is the short alias for /solve_queue (issue #1837)
1173
+ const handlers = { ...solveHandlers, ...taskHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
1173
1174
 
1174
1175
  const handler = handlers[extracted.command];
1175
1176
  if (!handler) return next();
@@ -97,11 +97,12 @@ export function registerSolveQueueCommand(bot, options) {
97
97
  });
98
98
  }
99
99
 
100
- // Match /solve_queue, /solve-queue, or /solvequeue (case-insensitive)
100
+ // Match /solve_queue, /solve-queue, /solvequeue, or the short /queue alias (case-insensitive)
101
101
  // Note: Telegram Bot API only supports underscores in command names, not hyphens.
102
- // The entity-based matching handles /solve_queue and /solvequeue.
102
+ // The entity-based matching handles /solve_queue, /solvequeue, and /queue.
103
103
  // /solve-queue is handled by the text-based fallback in telegram-bot.mjs (issue #1232).
104
- bot.command(/^solve[_-]?queue$/i, handleSolveQueueCommand);
104
+ // The /queue alias was added in issue #1837 to make checking the queue faster to type.
105
+ bot.command(/^(?:solve[_-]?queue|queue)$/i, handleSolveQueueCommand);
105
106
 
106
107
  return { handleSolveQueueCommand };
107
108
  }
@@ -6,6 +6,64 @@ import { lt } from './limits-i18n.lib.mjs';
6
6
 
7
7
  const execAsync = promisify(exec);
8
8
 
9
+ /**
10
+ * Build a clickable, human-readable link to a queued issue/PR for the
11
+ * /solve_queue (/queue) detailed status (issue #1837).
12
+ *
13
+ * For GitHub issue/PR URLs we render a compact `[owner/repo#number](url)`
14
+ * Markdown link so the list is scannable and clickable. When the label would
15
+ * contain Markdown-special characters (e.g. `_` or `*` in an owner/repo name)
16
+ * that could break Telegram's legacy Markdown parser, we fall back to the bare
17
+ * URL — which Telegram still auto-links and renders as clickable.
18
+ *
19
+ * Non-GitHub or unparseable URLs also fall back to the bare URL.
20
+ *
21
+ * @param {string} url - The issue/PR URL.
22
+ * @returns {string} A Markdown link or bare URL safe for `parse_mode: 'Markdown'`.
23
+ */
24
+ export function formatQueueItemLink(url) {
25
+ if (!url || typeof url !== 'string') return String(url ?? '');
26
+ const match = url.match(/github\.com\/([^/\s]+)\/([^/\s]+)\/(?:issues|pull)\/(\d+)/i);
27
+ if (!match) return url;
28
+ const [, owner, repo, number] = match;
29
+ const label = `${owner}/${repo}#${number}`;
30
+ // Only build a Markdown link when the label has no Markdown-special chars
31
+ // that would break the legacy parser inside link text. Otherwise the bare
32
+ // URL is still clickable in Telegram.
33
+ if (/^[A-Za-z0-9/#.-]+$/.test(label)) {
34
+ return `[${label}](${url})`;
35
+ }
36
+ return url;
37
+ }
38
+
39
+ /**
40
+ * Render a history section (Completed / Failed) for the detailed queue status
41
+ * as a clickable list, most-recent-first, capped at `max` items with a
42
+ * "... and N more" line (issue #1837).
43
+ *
44
+ * @param {object} opts
45
+ * @param {Array} opts.items - History items (each with `url`, optional `error`).
46
+ * @param {string} opts.emoji - Leading emoji for each row (e.g. '✅' or '❌').
47
+ * @param {string} opts.label - Localized section heading.
48
+ * @param {number} opts.max - Maximum items to list before collapsing.
49
+ * @param {string|null} opts.locale - Locale for the "and N more" label.
50
+ * @param {boolean} [opts.withError] - Append `— error` when the item failed.
51
+ * @returns {string} The formatted section (empty string when no items).
52
+ */
53
+ export function formatQueueHistorySection({ items, emoji, label, max, locale, withError = false }) {
54
+ if (!items || items.length === 0) return '';
55
+ let section = `*${label}* (${items.length}):\n`;
56
+ for (const item of [...items].reverse().slice(0, max)) {
57
+ section += ` ${emoji} ${formatQueueItemLink(item.url)}`;
58
+ if (withError && item.error) section += ` — ${item.error}`;
59
+ section += '\n';
60
+ }
61
+ if (items.length > max) {
62
+ section += ` ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
63
+ }
64
+ return `${section}\n`;
65
+ }
66
+
9
67
  /**
10
68
  * Count running processes by name.
11
69
  * @param {string} processName - Process name to search for (e.g., 'claude', 'agent', 'codex', 'gemini')
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
19
19
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
- import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
+ import { formatDuration, formatQueueHistorySection, formatQueueItemLink, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
21
21
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
22
22
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
23
23
  import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
@@ -1320,28 +1320,17 @@ export class SolveQueue {
1320
1320
  }
1321
1321
 
1322
1322
  /**
1323
- * Format detailed queue status for Telegram message
1324
- * Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
1323
+ * Format detailed queue status for Telegram message.
1324
+ * Groups output by tool queue (clickable links per item), then lists the
1325
+ * Completed and Failed history as clickable links, capped per section.
1325
1326
  *
1326
1327
  * Processing count = max(actual AI CLI processes via pgrep, tracked
1327
1328
  * `$ --status` executing screen-isolated sessions), not queue state.
1328
1329
  *
1329
- * Output format:
1330
- * ```
1331
- * 📋 Solve Queue Status
1332
- *
1333
- * claude (pending: 6, processing: 0)
1334
- * • url1 (waiting, 5h 43m 23s)
1335
- * └ RAM usage is 70% (threshold: 65%)
1336
- * • url2 (queued, 2m 15s)
1337
- *
1338
- * agent (pending: 2, processing: 0)
1339
- * • url3 (waiting, 1h 2m 5s)
1340
- * ```
1341
- *
1342
1330
  * @returns {Promise<string>}
1343
1331
  * @see https://github.com/link-assistant/hive-mind/issues/1159
1344
1332
  * @see https://github.com/link-assistant/hive-mind/issues/1267
1333
+ * @see https://github.com/link-assistant/hive-mind/issues/1837
1345
1334
  */
1346
1335
  async formatDetailedStatus(options = {}) {
1347
1336
  const locale = getLocale(options);
@@ -1359,22 +1348,37 @@ export class SolveQueue {
1359
1348
  const processing = externalProcessing.byTool[tool] || 0;
1360
1349
  message += `*${tool}* (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
1361
1350
 
1362
- // Show first 5 queued items for this tool
1363
- const displayItems = toolQueue.slice(0, 5);
1351
+ // Show the items this queue is actively processing for this tool, with a
1352
+ // clickable link to each issue/PR (issue #1837). These come from the
1353
+ // queue's own tracking, so they may differ from the pgrep-based count above.
1354
+ const processingItems = Array.from(this.processing.values()).filter(item => item.tool === tool);
1355
+ for (const item of processingItems.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE)) {
1356
+ const waitTime = formatDuration(item.getWaitTime(), { locale });
1357
+ message += ` ▶️ ${formatQueueItemLink(item.url)} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1358
+ }
1359
+
1360
+ // Show first queued items for this tool with clickable links
1361
+ const displayItems = toolQueue.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE);
1364
1362
  for (const item of displayItems) {
1365
1363
  const waitTime = formatDuration(item.getWaitTime(), { locale });
1366
- message += ` • ${item.url} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1364
+ message += ` • ${formatQueueItemLink(item.url)} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1367
1365
  if (item.waitingReason) {
1368
1366
  message += ` └ ${item.waitingReason}\n`;
1369
1367
  }
1370
1368
  }
1371
- if (toolQueue.length > 5) {
1372
- message += ` ... ${lt('queue_and_more', { count: toolQueue.length - 5 }, { locale })}\n`;
1369
+ if (toolQueue.length > QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE) {
1370
+ message += ` ... ${lt('queue_and_more', { count: toolQueue.length - QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE }, { locale })}\n`;
1373
1371
  }
1374
1372
 
1375
1373
  message += '\n';
1376
1374
  }
1377
1375
 
1376
+ // Completed / Failed lists - clickable links to the executed issues/PRs,
1377
+ // most-recent-first so the newest results are easy to find (issue #1837).
1378
+ const max = QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE;
1379
+ message += formatQueueHistorySection({ items: this.completed, emoji: '✅', label: lt('queue_completed', {}, { locale }), max, locale });
1380
+ message += formatQueueHistorySection({ items: this.failed, emoji: '❌', label: lt('queue_failed', {}, { locale }), max, locale, withError: true });
1381
+
1378
1382
  // Summary stats
1379
1383
  message += `${lt('queue_completed', {}, { locale })}: ${stats.completed}, ${lt('queue_failed', {}, { locale })}: ${stats.failed}\n`;
1380
1384