@link-assistant/hive-mind 1.77.1 → 1.78.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 +36 -0
- package/package.json +1 -1
- package/src/telegram-safe-reply.lib.mjs +112 -10
- package/src/telegram-solve-queue.helpers.lib.mjs +117 -14
- package/src/telegram-solve-queue.lib.mjs +38 -44
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.78.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9506f03: fix(telegram): de-duplicate `/queue` display and split long messages without breaking markdown (#1891)
|
|
8
|
+
|
|
9
|
+
The `/queue` (alias `/solve_queue`) detailed display repeated the same words on every
|
|
10
|
+
line — every executing row said `(processing, …)`, every waiting row said
|
|
11
|
+
`(waiting, …)`, and the (almost always identical) per-item waiting reason was printed
|
|
12
|
+
once per item. Empty queues were also still printed. This wasted vertical space and
|
|
13
|
+
pushed real data off screen.
|
|
14
|
+
|
|
15
|
+
Display changes (`formatDetailedStatus` + queue helpers):
|
|
16
|
+
- Executing rows now render compactly as `• owner/repo#number (▶️ <dur>)` and pending
|
|
17
|
+
rows as `• owner/repo#number (⏳ <dur>)` — the status word is replaced by the emoji
|
|
18
|
+
marker inside the duration parenthesis.
|
|
19
|
+
- Processing, pending, completed, and failed entries are split into distinct
|
|
20
|
+
compact lists per tool, with counts only on those list labels instead of a
|
|
21
|
+
duplicated `(pending: n, processing: n)` tool-header summary.
|
|
22
|
+
- The shared waiting reason is shown **once per tool** (only when all pending items
|
|
23
|
+
agree on it) instead of once per item.
|
|
24
|
+
- Empty queues are skipped entirely.
|
|
25
|
+
- All queued items are listed (no per-queue truncation on the active lists).
|
|
26
|
+
|
|
27
|
+
Message-splitting changes (`splitTelegramMessageText` in `telegram-safe-reply.lib.mjs`,
|
|
28
|
+
the single universal splitter every Telegram send path funnels through):
|
|
29
|
+
- Splitting now happens only on line boundaries, so inline Markdown entities
|
|
30
|
+
(bold/italic/links) are never cut in half.
|
|
31
|
+
- Fenced code blocks stay balanced per chunk: a split inside a code block closes the
|
|
32
|
+
fence at the end of one chunk and reopens it — repeating the language — at the start
|
|
33
|
+
of the next. The original fence marker (```vs`~~~`) and indentation are preserved.
|
|
34
|
+
- Pathologically long single lines are hard-split as a fallback.
|
|
35
|
+
|
|
36
|
+
Both behaviours are covered by extensive new tests
|
|
37
|
+
(`tests/test-telegram-message-split-1891.mjs`, `tests/test-queue-compact-display-1891.mjs`).
|
|
38
|
+
|
|
3
39
|
## 1.77.1
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -61,27 +61,129 @@ function findTelegramSplitIndex(text, limit) {
|
|
|
61
61
|
return limit;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
// A Markdown fenced code block opens/closes with ``` or ~~~ (optionally indented
|
|
65
|
+
// and, on the opening fence, followed by a language/info string). We track these
|
|
66
|
+
// so a split that lands inside a code block can close the fence on the current
|
|
67
|
+
// chunk and reopen it — repeating the language — on the next one (issue #1891).
|
|
68
|
+
const CODE_FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse a single line as a Markdown code-fence delimiter.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} line
|
|
74
|
+
* @returns {{indent: string, marker: string, info: string}|null}
|
|
75
|
+
* The fence parts, or `null` when the line is not a fence.
|
|
76
|
+
*/
|
|
77
|
+
export function parseCodeFence(line) {
|
|
78
|
+
const match = CODE_FENCE_RE.exec(line);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
return { indent: match[1], marker: match[2], info: match[3].trim() };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Hard-split a single physical line that is itself longer than `limit` into
|
|
85
|
+
* pieces that each fit, preferring a break at a natural separator near the end
|
|
86
|
+
* and falling back to a hard character cut. Used only for pathologically long
|
|
87
|
+
* lines (normal queue/help lines are short).
|
|
88
|
+
*
|
|
89
|
+
* @param {string} line
|
|
90
|
+
* @param {number} limit
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
function splitLongLine(line, limit) {
|
|
94
|
+
const pieces = [];
|
|
95
|
+
let remaining = line;
|
|
96
|
+
while (remaining.length > limit) {
|
|
97
|
+
let splitAt = findTelegramSplitIndex(remaining, limit);
|
|
98
|
+
if (splitAt <= 0 || splitAt > limit) splitAt = limit;
|
|
99
|
+
let piece = remaining.slice(0, splitAt);
|
|
100
|
+
if (!piece.trim()) {
|
|
101
|
+
splitAt = limit;
|
|
102
|
+
piece = remaining.slice(0, splitAt);
|
|
103
|
+
}
|
|
104
|
+
pieces.push(piece);
|
|
105
|
+
remaining = remaining.slice(splitAt);
|
|
106
|
+
}
|
|
107
|
+
if (remaining) pieces.push(remaining);
|
|
108
|
+
return pieces;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Split a (possibly oversized) Telegram message into chunks that each stay
|
|
113
|
+
* within `limit` characters.
|
|
114
|
+
*
|
|
115
|
+
* Splitting happens on line boundaries so inline Markdown entities (bold,
|
|
116
|
+
* italic, links — none of which may span a newline in Telegram's legacy
|
|
117
|
+
* Markdown) are never cut in half. Fenced code blocks, which *do* span lines,
|
|
118
|
+
* are kept valid across the split: when a break lands inside a code block the
|
|
119
|
+
* current chunk gets a closing fence appended and the next chunk re-opens the
|
|
120
|
+
* fence with the same marker and language (issue #1891).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} text
|
|
123
|
+
* @param {number} [limit=TELEGRAM_TEXT_LIMIT]
|
|
124
|
+
* @returns {string[]} One or more chunks; always at least one element.
|
|
125
|
+
*/
|
|
64
126
|
export function splitTelegramMessageText(text, limit = TELEGRAM_TEXT_LIMIT) {
|
|
65
127
|
const source = String(text ?? '');
|
|
66
128
|
if (source.length <= limit) return [source];
|
|
67
129
|
|
|
130
|
+
// Reserve headroom on each physical line for a possible fence reopen/close
|
|
131
|
+
// pair so re-wrapping a code block never pushes a chunk past the limit.
|
|
132
|
+
const FENCE_HEADROOM = 16;
|
|
133
|
+
const lineLimit = Math.max(1, limit - FENCE_HEADROOM);
|
|
134
|
+
|
|
135
|
+
// Expand into physical lines, pre-splitting any line that alone exceeds the
|
|
136
|
+
// budget so the chunker below only ever deals with lines that fit.
|
|
137
|
+
const lines = [];
|
|
138
|
+
for (const raw of source.split('\n')) {
|
|
139
|
+
if (raw.length <= lineLimit) lines.push(raw);
|
|
140
|
+
else lines.push(...splitLongLine(raw, lineLimit));
|
|
141
|
+
}
|
|
142
|
+
|
|
68
143
|
const chunks = [];
|
|
69
|
-
let
|
|
144
|
+
let current = '';
|
|
145
|
+
let openFence = null; // { indent, marker, info } while inside a code block
|
|
70
146
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let chunk = remaining.slice(0, splitAt).trimEnd();
|
|
147
|
+
const closeFenceLine = () => `${openFence.indent}${openFence.marker}`;
|
|
148
|
+
const reopenFenceLine = () => `${openFence.indent}${openFence.marker}${openFence.info}`;
|
|
74
149
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
150
|
+
const flush = () => {
|
|
151
|
+
let chunk = current;
|
|
152
|
+
if (openFence) {
|
|
153
|
+
// Close the still-open code block at the end of this chunk.
|
|
154
|
+
chunk = chunk.length ? `${chunk}\n${closeFenceLine()}` : closeFenceLine();
|
|
78
155
|
}
|
|
79
|
-
|
|
80
156
|
chunks.push(chunk);
|
|
81
|
-
|
|
157
|
+
// Re-open the fence at the start of the next chunk so the code block (and
|
|
158
|
+
// its language) continues seamlessly.
|
|
159
|
+
current = openFence ? reopenFenceLine() : '';
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
const separatorLength = current.length ? 1 : 0;
|
|
164
|
+
const closeReserve = openFence ? closeFenceLine().length + 1 : 0;
|
|
165
|
+
const projected = current.length + separatorLength + line.length + closeReserve;
|
|
166
|
+
|
|
167
|
+
if (current.length > 0 && projected > limit) {
|
|
168
|
+
flush();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
current = current.length ? `${current}\n${line}` : line;
|
|
172
|
+
|
|
173
|
+
const fence = parseCodeFence(line);
|
|
174
|
+
if (fence) {
|
|
175
|
+
// A fence line toggles code-block state: open when outside, close when
|
|
176
|
+
// already inside.
|
|
177
|
+
openFence = openFence ? null : fence;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Defensive: close any fence left open by unbalanced input.
|
|
182
|
+
if (openFence && current.length) {
|
|
183
|
+
current = `${current}\n${closeFenceLine()}`;
|
|
82
184
|
}
|
|
185
|
+
if (current.length || chunks.length === 0) chunks.push(current);
|
|
83
186
|
|
|
84
|
-
if (remaining) chunks.push(remaining);
|
|
85
187
|
return chunks;
|
|
86
188
|
}
|
|
87
189
|
|
|
@@ -50,18 +50,18 @@ export function formatQueueItemLink(url) {
|
|
|
50
50
|
* @param {boolean} [opts.withError] - Append `— error` when the item failed.
|
|
51
51
|
* @returns {string} The formatted section (empty string when no items).
|
|
52
52
|
*/
|
|
53
|
-
export function formatQueueHistorySection({ items, emoji, label, max, locale, withError = false }) {
|
|
53
|
+
export function formatQueueHistorySection({ items, emoji, label, max, locale, withError = false, indent = '' }) {
|
|
54
54
|
if (!items || items.length === 0) return '';
|
|
55
|
-
let section =
|
|
55
|
+
let section = `${indent}*${label}* (${items.length}):\n`;
|
|
56
56
|
for (const item of [...items].reverse().slice(0, max)) {
|
|
57
|
-
section +=
|
|
57
|
+
section += `${indent} ${emoji} ${formatQueueItemLink(item.url)}`;
|
|
58
58
|
if (withError && item.error) section += ` — ${item.error}`;
|
|
59
59
|
section += '\n';
|
|
60
60
|
}
|
|
61
61
|
if (items.length > max) {
|
|
62
|
-
section +=
|
|
62
|
+
section += `${indent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
|
|
63
63
|
}
|
|
64
|
-
return
|
|
64
|
+
return section;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/**
|
|
@@ -128,25 +128,128 @@ export function collectExecutingItems({ processingItems = [], sessionItems = [],
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
|
-
* Render the per-tool "executing" lines
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* Render the per-tool "executing" lines for the detailed queue status as a
|
|
132
|
+
* compact, de-duplicated list (issue #1891):
|
|
133
|
+
*
|
|
134
|
+
* `• owner/repo#number (▶️ 2h 14m 16s)`
|
|
135
|
+
*
|
|
136
|
+
* The ▶️ emoji replaces the repeated literal "processing" status word that
|
|
137
|
+
* appeared on every line in the old format. Items are listed in full by
|
|
138
|
+
* default; pass a finite `max` to cap them with a localized "... and N more"
|
|
139
|
+
* line.
|
|
134
140
|
*
|
|
135
141
|
* @param {object} opts
|
|
136
142
|
* @param {Array} opts.items - Output of {@link collectExecutingItems}.
|
|
137
|
-
* @param {number} opts.max - Maximum items
|
|
143
|
+
* @param {number} [opts.max=Infinity] - Maximum items before collapsing.
|
|
144
|
+
* @param {string|null} opts.locale - Locale for labels/durations.
|
|
145
|
+
* @param {string} [opts.label] - Optional sub-list heading (e.g. "Processing").
|
|
146
|
+
* When set, a ` *Label* (count):` header is rendered above the items so each
|
|
147
|
+
* status gets its own clearly-labeled list (issue #1891 follow-up).
|
|
148
|
+
* @returns {string} The formatted lines (empty string when no items).
|
|
149
|
+
*/
|
|
150
|
+
export function formatQueueExecutingItems({ items, max = Infinity, locale, label }) {
|
|
151
|
+
if (!items || items.length === 0) return '';
|
|
152
|
+
// Items nest one level under their section label when labeled (issue #1891).
|
|
153
|
+
const itemIndent = label ? ' ' : ' ';
|
|
154
|
+
let out = label ? ` *${label}* (${items.length}):\n` : '';
|
|
155
|
+
for (const item of items.slice(0, max)) {
|
|
156
|
+
out += `${itemIndent}• ${formatQueueItemLink(item.url)} (▶️ ${formatDuration(item.waitMs, { locale })})\n`;
|
|
157
|
+
}
|
|
158
|
+
if (items.length > max) {
|
|
159
|
+
out += `${itemIndent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Backwards-compatible alias for {@link formatQueueExecutingItems}.
|
|
166
|
+
* @deprecated Use {@link formatQueueExecutingItems}.
|
|
167
|
+
*/
|
|
168
|
+
export const formatQueueProcessingItems = formatQueueExecutingItems;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Group queue history items (completed/failed) by their `tool` key so each tool
|
|
172
|
+
* queue can render its own Completed/Failed list (issue #1891 follow-up).
|
|
173
|
+
*
|
|
174
|
+
* @param {Array<{tool?: string}>} items
|
|
175
|
+
* @returns {Object<string, Array>} Map of tool key → items.
|
|
176
|
+
*/
|
|
177
|
+
export function groupQueueItemsByTool(items) {
|
|
178
|
+
const byTool = {};
|
|
179
|
+
for (const item of items || []) {
|
|
180
|
+
const tool = item.tool || 'claude';
|
|
181
|
+
(byTool[tool] ||= []).push(item);
|
|
182
|
+
}
|
|
183
|
+
return byTool;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render one tool queue's block for the detailed status as a set of *separate,
|
|
188
|
+
* individually-labeled lists* — Processing, Pending, Completed, Failed — instead
|
|
189
|
+
* of one merged bullet list (issue #1891 follow-up). Empty lists are omitted.
|
|
190
|
+
*
|
|
191
|
+
* @param {object} opts
|
|
192
|
+
* @param {string} opts.tool - Tool key (header label).
|
|
193
|
+
* @param {Array} opts.executing - Output of {@link collectExecutingItems}.
|
|
194
|
+
* @param {Array} opts.pendingItems - `{url, waitMs, waitingReason}` per pending item.
|
|
195
|
+
* @param {Array} opts.completed - Completed history items for this tool.
|
|
196
|
+
* @param {Array} opts.failed - Failed history items for this tool.
|
|
197
|
+
* @param {object} opts.labels - Localized labels (`pendingLower`, `processingLower`,
|
|
198
|
+
* `pending`, `processing`, `completed`, `failed`).
|
|
199
|
+
* @param {number} opts.max - Max history items before the "… and N more" collapse.
|
|
200
|
+
* @param {string|null} opts.locale - Locale for labels/durations.
|
|
201
|
+
* @returns {string} The formatted block (with a trailing blank line).
|
|
202
|
+
*/
|
|
203
|
+
export function formatQueueToolSection({ tool, executing, pendingItems, completed, failed, labels, max, locale }) {
|
|
204
|
+
let block = `*${tool}*\n`;
|
|
205
|
+
|
|
206
|
+
// Processing list.
|
|
207
|
+
block += formatQueueExecutingItems({ items: executing, locale, label: labels.processing });
|
|
208
|
+
|
|
209
|
+
// Pending list + the shared waiting reason once (when all items agree on it).
|
|
210
|
+
block += formatQueuePendingItems({ items: pendingItems, locale, label: labels.pending });
|
|
211
|
+
const distinctReasons = [...new Set(pendingItems.map(item => item.waitingReason).filter(Boolean))];
|
|
212
|
+
if (distinctReasons.length === 1) {
|
|
213
|
+
block += ` ⏳ ${distinctReasons[0].replace(/\n+/g, '; ')}\n`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Completed / Failed lists for this tool (most-recent-first, capped), indented
|
|
217
|
+
// so the label sits under the tool header and items under it.
|
|
218
|
+
block += formatQueueHistorySection({ items: completed, emoji: '✅', label: labels.completed, max, locale, indent: ' ' });
|
|
219
|
+
block += formatQueueHistorySection({ items: failed, emoji: '❌', label: labels.failed, max, locale, withError: true, indent: ' ' });
|
|
220
|
+
|
|
221
|
+
return `${block}\n`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Render the per-tool "pending/waiting" lines for the detailed queue status as a
|
|
226
|
+
* compact list (issue #1891):
|
|
227
|
+
*
|
|
228
|
+
* `• owner/repo#number (⏳ 5m 2s)`
|
|
229
|
+
*
|
|
230
|
+
* The per-item waiting *reason* is deliberately omitted here — it is almost
|
|
231
|
+
* always identical across pending items, so the caller shows it once for the
|
|
232
|
+
* whole tool instead of repeating it on every line.
|
|
233
|
+
*
|
|
234
|
+
* @param {object} opts
|
|
235
|
+
* @param {Array<{url: string, waitMs: number}>} opts.items - Pending items.
|
|
236
|
+
* @param {number} [opts.max=Infinity] - Maximum items before collapsing.
|
|
138
237
|
* @param {string|null} opts.locale - Locale for labels/durations.
|
|
238
|
+
* @param {string} [opts.label] - Optional sub-list heading (e.g. "Pending").
|
|
239
|
+
* When set, a ` *Label* (count):` header is rendered above the items so each
|
|
240
|
+
* status gets its own clearly-labeled list (issue #1891 follow-up).
|
|
139
241
|
* @returns {string} The formatted lines (empty string when no items).
|
|
140
242
|
*/
|
|
141
|
-
export function
|
|
243
|
+
export function formatQueuePendingItems({ items, max = Infinity, locale, label }) {
|
|
142
244
|
if (!items || items.length === 0) return '';
|
|
143
|
-
|
|
245
|
+
// Items nest one level under their section label when labeled (issue #1891).
|
|
246
|
+
const itemIndent = label ? ' ' : ' ';
|
|
247
|
+
let out = label ? ` *${label}* (${items.length}):\n` : '';
|
|
144
248
|
for (const item of items.slice(0, max)) {
|
|
145
|
-
|
|
146
|
-
out += ` ▶️ ${formatQueueItemLink(item.url)} (${label}, ${formatDuration(item.waitMs, { locale })})\n`;
|
|
249
|
+
out += `${itemIndent}• ${formatQueueItemLink(item.url)} (⏳ ${formatDuration(item.waitMs, { locale })})\n`;
|
|
147
250
|
}
|
|
148
251
|
if (items.length > max) {
|
|
149
|
-
out +=
|
|
252
|
+
out += `${itemIndent} ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
|
|
150
253
|
}
|
|
151
254
|
return out;
|
|
152
255
|
}
|
|
@@ -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 { collectExecutingItems, formatDuration,
|
|
20
|
+
import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems, groupQueueItemsByTool } 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';
|
|
@@ -46,10 +46,6 @@ function appendRemainingDuration(reason, ms, locale) {
|
|
|
46
46
|
return `${reason} (${lt('remaining', { duration: formatDuration(ms, { locale }) }, { locale })})`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function queueStatusLabel(status, locale) {
|
|
50
|
-
return lt(`queue_status_${status}`, {}, { locale });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
49
|
/**
|
|
54
50
|
* Queue item representing a /solve command request
|
|
55
51
|
*/
|
|
@@ -1324,65 +1320,63 @@ export class SolveQueue {
|
|
|
1324
1320
|
|
|
1325
1321
|
/**
|
|
1326
1322
|
* Format detailed queue status for Telegram message.
|
|
1327
|
-
* Groups output by tool queue (clickable links per item), then lists the
|
|
1328
|
-
* Completed and Failed history as clickable links, capped per section.
|
|
1329
1323
|
*
|
|
1330
|
-
*
|
|
1331
|
-
*
|
|
1324
|
+
* Each tool queue renders as a set of *separate, individually-labeled lists* —
|
|
1325
|
+
* Processing, Pending, Completed, Failed — instead of one merged bullet list
|
|
1326
|
+
* (issue #1891). Empty lists (and fully-empty tool queues) are skipped, items
|
|
1327
|
+
* render as compact `• link (emoji dur)` rows, and the shared waiting reason
|
|
1328
|
+
* is shown once per tool.
|
|
1332
1329
|
*
|
|
1333
1330
|
* @returns {Promise<string>}
|
|
1334
|
-
* @see https://github.com/link-assistant/hive-mind/issues/1159
|
|
1335
1331
|
* @see https://github.com/link-assistant/hive-mind/issues/1267
|
|
1336
1332
|
* @see https://github.com/link-assistant/hive-mind/issues/1837
|
|
1333
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1891
|
|
1337
1334
|
*/
|
|
1338
1335
|
async formatDetailedStatus(options = {}) {
|
|
1339
1336
|
const locale = getLocale(options);
|
|
1340
1337
|
const stats = this.getStats();
|
|
1341
|
-
const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
|
|
1342
1338
|
// Currently-executing detached sessions (with issue/PR URLs). These are the
|
|
1343
1339
|
// real running tasks; the queue's own `processing` Map is emptied once a task
|
|
1344
1340
|
// is dispatched, so without this the executing items are never listed (#1837).
|
|
1345
1341
|
const runningSessionItems = await this.getRunningSessionItemsFn(this.verbose);
|
|
1346
1342
|
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1349
|
-
|
|
1343
|
+
// Section labels: each per-tool sub-list uses a capitalized heading.
|
|
1344
|
+
const cap = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
1345
|
+
const labels = {
|
|
1346
|
+
pending: cap(lt('queue_pending', {}, { locale })),
|
|
1347
|
+
processing: cap(lt('queue_processing', {}, { locale })),
|
|
1348
|
+
completed: cap(lt('queue_completed', {}, { locale })),
|
|
1349
|
+
failed: cap(lt('queue_failed', {}, { locale })),
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// Group the (globally-capped) completed/failed history by tool so each tool
|
|
1353
|
+
// queue shows its own Completed/Failed list (issue #1891 follow-up).
|
|
1354
|
+
const completedByTool = groupQueueItemsByTool(this.completed);
|
|
1355
|
+
const failedByTool = groupQueueItemsByTool(this.failed);
|
|
1356
|
+
|
|
1350
1357
|
let message = `📋 *${lt('solve_queue_status', {}, { locale })}*\n\n`;
|
|
1358
|
+
const max = QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE;
|
|
1351
1359
|
|
|
1352
|
-
//
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
const processing = externalProcessing.byTool[tool] || 0;
|
|
1356
|
-
message += `*${tool}* (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
|
|
1360
|
+
// Every tool with *any* activity (queued, processing, or in history) so
|
|
1361
|
+
// per-tool history shows even after a tool's live queue has drained.
|
|
1362
|
+
const tools = [...new Set([...Object.keys(this.queues), ...Object.keys(completedByTool), ...Object.keys(failedByTool)])];
|
|
1357
1363
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
//
|
|
1364
|
+
for (const tool of tools) {
|
|
1365
|
+
const toolQueue = this.queues[tool] || [];
|
|
1366
|
+
const pending = toolQueue.length;
|
|
1367
|
+
// Executing tasks: merge the in-memory processing Map with tracked
|
|
1368
|
+
// detached sessions, deduped by URL, so they list even after dispatch
|
|
1369
|
+
// (issue #1837).
|
|
1362
1370
|
const executing = collectExecutingItems({ processingItems: this.processing.values(), sessionItems: runningSessionItems, tool });
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
// Show first queued items for this tool with clickable links
|
|
1366
|
-
const displayItems = toolQueue.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE);
|
|
1367
|
-
for (const item of displayItems) {
|
|
1368
|
-
const waitTime = formatDuration(item.getWaitTime(), { locale });
|
|
1369
|
-
message += ` • ${formatQueueItemLink(item.url)} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
|
|
1370
|
-
if (item.waitingReason) {
|
|
1371
|
-
message += ` └ ${item.waitingReason}\n`;
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
if (toolQueue.length > QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE) {
|
|
1375
|
-
message += ` ... ${lt('queue_and_more', { count: toolQueue.length - QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE }, { locale })}\n`;
|
|
1376
|
-
}
|
|
1371
|
+
const completed = completedByTool[tool] || [];
|
|
1372
|
+
const failed = failedByTool[tool] || [];
|
|
1377
1373
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1374
|
+
// Skip tools with nothing to show in any list.
|
|
1375
|
+
if (pending === 0 && executing.length === 0 && completed.length === 0 && failed.length === 0) continue;
|
|
1380
1376
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
message += formatQueueHistorySection({ items: this.completed, emoji: '✅', label: lt('queue_completed', {}, { locale }), max, locale });
|
|
1385
|
-
message += formatQueueHistorySection({ items: this.failed, emoji: '❌', label: lt('queue_failed', {}, { locale }), max, locale, withError: true });
|
|
1377
|
+
const pendingItems = toolQueue.map(item => ({ url: item.url, waitMs: item.getWaitTime(), waitingReason: item.waitingReason }));
|
|
1378
|
+
message += formatQueueToolSection({ tool, executing, pendingItems, completed, failed, labels, max, locale });
|
|
1379
|
+
}
|
|
1386
1380
|
|
|
1387
1381
|
// Summary stats
|
|
1388
1382
|
message += `${lt('queue_completed', {}, { locale })}: ${stats.completed}, ${lt('queue_failed', {}, { locale })}: ${stats.failed}\n`;
|