@link-assistant/hive-mind 1.56.7 → 1.56.9

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,18 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.56.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 94448c3: Fix screen-isolated work-session Telegram updates so executing messages stay compact and completion messages use `$ --status` start/end timestamps and exit codes.
8
+
9
+ ## 1.56.8
10
+
11
+ ### Patch Changes
12
+
13
+ - 05a3e42: Fix CI/CD change detection for pull request synchronize events so metadata-only updates skip expensive test jobs while still reporting completed checks.
14
+ - c12f99d: Fix screen-isolated solve monitoring so completed `$ --status` sessions no longer block duplicate commands, queued status displays executing isolation sessions, and Telegram start messages stay in an executing state until completion.
15
+
3
16
  ## 1.56.7
4
17
 
5
18
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.56.7",
3
+ "version": "1.56.9",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "hive-telegram-bot": "./src/telegram-bot.mjs"
16
16
  },
17
17
  "scripts": {
18
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
18
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-telegram-bot-launcher.mjs",
19
19
  "test:queue": "node tests/solve-queue.test.mjs",
20
20
  "test:limits-display": "node tests/limits-display.test.mjs",
21
21
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -22,6 +22,8 @@ const { $ } = await use('command-stream');
22
22
 
23
23
  // Valid isolation backends
24
24
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
+ const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
26
+ const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
25
27
 
26
28
  /**
27
29
  * Generate a UUID v4 for unique session identification
@@ -31,6 +33,76 @@ export function generateSessionId() {
31
33
  return crypto.randomUUID();
32
34
  }
33
35
 
36
+ /**
37
+ * Parse output from `$ --status <session>`.
38
+ *
39
+ * start-command versions used in the wild may return JSON when
40
+ * `--output-format json` is supported, or human-readable key/value text.
41
+ * Keep the parser tolerant so completion monitoring survives either format.
42
+ *
43
+ * @param {string} output - Raw stdout from `$ --status`
44
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}}
45
+ */
46
+ export function parseSessionStatusOutput(output) {
47
+ const raw = (output || '').trim();
48
+ if (!raw) {
49
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
50
+ }
51
+
52
+ try {
53
+ const parsed = JSON.parse(raw);
54
+ const data = Array.isArray(parsed) ? parsed[0] : parsed;
55
+ return {
56
+ exists: true,
57
+ uuid: data?.uuid || null,
58
+ status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
59
+ exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
60
+ startTime: data?.startTime || null,
61
+ endTime: data?.endTime || null,
62
+ currentTime: data?.currentTime || null,
63
+ raw,
64
+ };
65
+ } catch {
66
+ // Fall through to text parsing.
67
+ }
68
+
69
+ const firstLine =
70
+ raw
71
+ .split('\n')
72
+ .find(line => line.trim() && !line.includes(' '))
73
+ ?.trim() || null;
74
+ const readField = name => {
75
+ const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
76
+ return match ? match[1].trim() : null;
77
+ };
78
+
79
+ const status = readField('status')?.toLowerCase() || null;
80
+ const exitCodeText = readField('exitCode');
81
+
82
+ return {
83
+ exists: Boolean(status || firstLine),
84
+ uuid: readField('uuid') || firstLine,
85
+ status,
86
+ exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
87
+ startTime: readField('startTime'),
88
+ endTime: readField('endTime'),
89
+ currentTime: readField('currentTime'),
90
+ raw,
91
+ };
92
+ }
93
+
94
+ export function isExecutingSessionStatus(status) {
95
+ return RUNNING_SESSION_STATUSES.has(String(status || '').toLowerCase());
96
+ }
97
+
98
+ export function isTerminalSessionStatus(status) {
99
+ return TERMINAL_SESSION_STATUSES.has(String(status || '').toLowerCase());
100
+ }
101
+
102
+ export function shouldFallbackToScreenStatus(statusResult) {
103
+ return !statusResult?.exists || !statusResult?.status;
104
+ }
105
+
34
106
  /**
35
107
  * Find the `$` CLI binary path
36
108
  * @returns {Promise<string|null>} Path to `$` binary or null
@@ -133,7 +205,7 @@ export async function executeWithIsolation(command, args, options = {}) {
133
205
  *
134
206
  * @param {string} sessionId - UUID of the session to check
135
207
  * @param {boolean} [verbose] - Enable verbose logging
136
- * @returns {Promise<{exists: boolean, status: string|null, exitCode: number|null, raw: string}>}
208
+ * @returns {Promise<{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}>}
137
209
  */
138
210
  export async function querySessionStatus(sessionId, verbose = false) {
139
211
  const binPath = await findStartCommandBinary();
@@ -141,7 +213,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
141
213
  if (verbose) {
142
214
  console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
143
215
  }
144
- return { exists: false, status: null, exitCode: null, raw: '' };
216
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
145
217
  }
146
218
 
147
219
  try {
@@ -153,30 +225,12 @@ export async function querySessionStatus(sessionId, verbose = false) {
153
225
  console.log(`[VERBOSE] isolation-runner: Status query result: ${stdout.substring(0, 300)}`);
154
226
  }
155
227
 
156
- try {
157
- const data = JSON.parse(stdout);
158
- return {
159
- exists: true,
160
- status: data.status || null,
161
- exitCode: data.exitCode !== undefined ? data.exitCode : null,
162
- raw: stdout,
163
- };
164
- } catch {
165
- // If JSON parsing fails, try text-based detection
166
- const isExecuting = stdout.includes('executing');
167
- const isExecuted = stdout.includes('executed');
168
- return {
169
- exists: isExecuting || isExecuted,
170
- status: isExecuting ? 'executing' : isExecuted ? 'executed' : null,
171
- exitCode: null,
172
- raw: stdout,
173
- };
174
- }
228
+ return parseSessionStatusOutput(stdout);
175
229
  } catch (error) {
176
230
  if (verbose) {
177
231
  console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
178
232
  }
179
- return { exists: false, status: null, exitCode: null, raw: '' };
233
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
180
234
  }
181
235
  }
182
236
 
@@ -222,16 +276,21 @@ export async function isSessionRunning(sessionId, options = {}) {
222
276
  const { backend, verbose = false } = opts;
223
277
 
224
278
  const result = await querySessionStatus(sessionId, verbose);
225
- if (result.exists && result.status === 'executing') {
226
- return true;
279
+ if (result.exists && result.status) {
280
+ if (isExecutingSessionStatus(result.status)) {
281
+ return true;
282
+ }
283
+ if (isTerminalSessionStatus(result.status)) {
284
+ return false;
285
+ }
227
286
  }
228
287
 
229
288
  // Fallback: for screen backend, check screen -ls directly.
230
- // This works around start-command bugs where:
289
+ // Only use this when $ --status has no usable record. This works around
290
+ // older start-command bugs where:
231
291
  // 1. $ --status can't find session by --session name (only by internal UUID)
232
- // 2. $ --status reports "executed" immediately for --detached screen sessions
233
292
  // See: https://github.com/link-assistant/hive-mind/issues/1545
234
- if (backend === 'screen') {
293
+ if (backend === 'screen' && shouldFallbackToScreenStatus(result)) {
235
294
  const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
236
295
  if (screenRunning && verbose) {
237
296
  console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
@@ -17,6 +17,9 @@
17
17
 
18
18
  import { promisify } from 'util';
19
19
  import { exec as execCallback } from 'child_process';
20
+ import { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
21
+
22
+ export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
20
23
 
21
24
  const exec = promisify(execCallback);
22
25
 
@@ -28,12 +31,6 @@ async function getIsolationRunner() {
28
31
  }
29
32
  return _isolationRunner;
30
33
  }
31
- // Legacy accessor for querySessionStatus
32
- async function getQuerySessionStatus() {
33
- const mod = await getIsolationRunner();
34
- return mod.querySessionStatus;
35
- }
36
-
37
34
  // In-memory session store
38
35
  const activeSessions = new Map();
39
36
 
@@ -65,51 +62,6 @@ export async function checkScreenSessionExists(sessionName) {
65
62
  }
66
63
  }
67
64
 
68
- /**
69
- * Check if an isolated session is still running.
70
- * Uses isolation-runner's isSessionRunning which includes screen -ls fallback
71
- * for screen-backend sessions to work around start-command UUID mismatch.
72
- *
73
- * @param {string} sessionId - UUID of the isolated session (screen session name)
74
- * @param {Object} [options] - Options
75
- * @param {string} [options.backend] - Isolation backend ('screen', 'tmux', 'docker')
76
- * @param {boolean} [options.verbose] - Whether to log verbose output
77
- * @returns {Promise<boolean>} True if session is still running
78
- * @see https://github.com/link-assistant/hive-mind/issues/1545
79
- */
80
- async function checkIsolatedSessionRunning(sessionId, options = {}) {
81
- const opts = typeof options === 'boolean' ? { verbose: options } : options;
82
- const { backend, verbose = false } = opts;
83
- try {
84
- const runner = await getIsolationRunner();
85
- return await runner.isSessionRunning(sessionId, { backend, verbose });
86
- } catch (error) {
87
- if (verbose) {
88
- console.error(`[VERBOSE] Error checking isolated session ${sessionId}: ${error.message}`);
89
- }
90
- return false;
91
- }
92
- }
93
-
94
- /**
95
- * Get the exit code of a completed isolated session
96
- * @param {string} sessionId - UUID of the isolated session
97
- * @param {boolean} verbose - Whether to log verbose output
98
- * @returns {Promise<number|null>} Exit code or null if unknown
99
- */
100
- async function getIsolatedSessionExitCode(sessionId, verbose = false) {
101
- try {
102
- const queryStatus = await getQuerySessionStatus();
103
- const result = await queryStatus(sessionId, verbose);
104
- if (result.exists && result.status === 'executed') {
105
- return result.exitCode;
106
- }
107
- return null;
108
- } catch {
109
- return null;
110
- }
111
- }
112
-
113
65
  /**
114
66
  * Track a new session for completion monitoring
115
67
  *
@@ -172,6 +124,67 @@ function completeSession(sessionName, exitCode = 0, verbose = false) {
172
124
  }
173
125
  }
174
126
 
127
+ function normalizeSessionUrl(url) {
128
+ return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
129
+ }
130
+
131
+ function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
132
+ const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
133
+ const elapsed = Date.now() - startTime.getTime();
134
+ if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
135
+ if (verbose) {
136
+ console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
137
+ }
138
+ activeSessions.delete(sessionName);
139
+ return false;
140
+ }
141
+ if (verbose) {
142
+ const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
143
+ console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
144
+ }
145
+ return true;
146
+ }
147
+
148
+ async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
149
+ const { verbose = false, statusProvider = null } = options;
150
+ const sessionId = sessionInfo.sessionId || sessionName;
151
+
152
+ try {
153
+ const runner = await getIsolationRunner();
154
+ const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
155
+
156
+ if (statusResult?.exists && statusResult.status) {
157
+ if (runner.isExecutingSessionStatus(statusResult.status)) {
158
+ return { running: true, exitCode: null, status: statusResult.status, statusResult };
159
+ }
160
+ if (runner.isTerminalSessionStatus(statusResult.status)) {
161
+ return {
162
+ running: false,
163
+ exitCode: statusResult.exitCode !== undefined ? statusResult.exitCode : null,
164
+ status: statusResult.status,
165
+ statusResult,
166
+ };
167
+ }
168
+ }
169
+
170
+ const running = await runner.isSessionRunning(sessionId, {
171
+ backend: sessionInfo.isolationBackend,
172
+ verbose,
173
+ });
174
+ return {
175
+ running,
176
+ exitCode: running ? null : (statusResult?.exitCode ?? null),
177
+ status: statusResult?.status || null,
178
+ statusResult,
179
+ };
180
+ } catch (error) {
181
+ if (verbose) {
182
+ console.error(`[VERBOSE] Error refreshing isolated session ${sessionId}: ${error.message}`);
183
+ }
184
+ return { running: false, exitCode: null, status: null, statusResult: null };
185
+ }
186
+ }
187
+
175
188
  /**
176
189
  * Monitor active sessions and send notifications when they complete
177
190
  * @param {Object} bot - Telegraf bot instance for sending messages
@@ -191,17 +204,16 @@ export async function monitorSessions(bot, verbose = false) {
191
204
  for (const { sessionName, sessionInfo } of sessions) {
192
205
  let stillRunning;
193
206
  let exitCode = null;
207
+ let statusResult = null;
194
208
 
195
209
  if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
196
- // Isolation mode: use $ --status with screen -ls fallback for screen backend
197
- // See: https://github.com/link-assistant/hive-mind/issues/1545
198
- stillRunning = await checkIsolatedSessionRunning(sessionInfo.sessionId, {
199
- backend: sessionInfo.isolationBackend,
200
- verbose,
201
- });
202
- if (!stillRunning) {
203
- exitCode = await getIsolatedSessionExitCode(sessionInfo.sessionId, verbose);
204
- }
210
+ // Isolation mode: use $ --status, with screen -ls only as a fallback
211
+ // when the status record is unavailable. Terminal $ statuses are
212
+ // authoritative so completed screen sessions do not stay blocked.
213
+ const state = await getIsolationSessionState(sessionName, sessionInfo, { verbose });
214
+ stillRunning = state.running;
215
+ exitCode = state.exitCode;
216
+ statusResult = state.statusResult;
205
217
  } else {
206
218
  // Issue #1586: Non-isolation screen sessions cannot reliably detect
207
219
  // completion because start-screen keeps the screen alive via `exec bash`.
@@ -226,21 +238,14 @@ export async function monitorSessions(bot, verbose = false) {
226
238
  console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
227
239
 
228
240
  try {
229
- const endTime = new Date();
230
- const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
231
- const duration = Math.round((endTime - startTime) / 1000);
232
- const minutes = Math.floor(duration / 60);
233
- const seconds = duration % 60;
234
-
235
- const statusEmoji = exitCode === null || exitCode === 0 ? 'āœ…' : 'āŒ';
236
- const statusText = exitCode === null || exitCode === 0 ? 'Completed' : `Failed (exit code: ${exitCode})`;
237
- const isolationInfo = sessionInfo.isolationBackend ? `\nšŸ”’ Isolation: ${sessionInfo.isolationBackend}` : '';
238
-
239
- let message = `${statusEmoji} *Work Session ${statusText}*\n\n`;
240
- message += `šŸ“Š Session: \`${sessionName}\`\n`;
241
- message += `ā±ļø Duration: ${minutes}m ${seconds}s\n`;
242
- message += `šŸ”— URL: ${sessionInfo.url}${isolationInfo}\n\n`;
243
- message += `The work session has finished. You can now review the results.`;
241
+ const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
242
+ const message = formatSessionCompletionMessage({
243
+ sessionName,
244
+ sessionInfo,
245
+ statusResult,
246
+ observedEndTime: new Date(),
247
+ exitCode: finalExitCode,
248
+ });
244
249
 
245
250
  // Update the original reply message if messageId is available, otherwise send new message
246
251
  if (sessionInfo.messageId) {
@@ -249,7 +254,7 @@ export async function monitorSessions(bot, verbose = false) {
249
254
  await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
250
255
  }
251
256
 
252
- completeSession(sessionName, exitCode || 0, verbose);
257
+ completeSession(sessionName, finalExitCode || 0, verbose);
253
258
  } catch (error) {
254
259
  console.error(`Failed to send completion notification for ${sessionName}:`, error);
255
260
  completeSession(sessionName, 1, verbose);
@@ -294,27 +299,14 @@ export function hasActiveSessionForUrl(url, verbose = false) {
294
299
  if (!url) return { isActive: false, sessionName: null };
295
300
 
296
301
  // Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
297
- const normalizeUrl = u => u.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
298
- const normalizedUrl = normalizeUrl(url);
302
+ const normalizedUrl = normalizeSessionUrl(url);
299
303
 
300
304
  for (const [sessionName, sessionInfo] of activeSessions.entries()) {
301
305
  // Issue #1586: Auto-expire non-isolation sessions after timeout
302
- if (!sessionInfo.isolationBackend) {
303
- const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
304
- const elapsed = Date.now() - startTime.getTime();
305
- if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
306
- if (verbose) {
307
- console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
308
- }
309
- activeSessions.delete(sessionName);
310
- continue;
311
- }
312
- if (verbose) {
313
- const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
314
- console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
315
- }
306
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
307
+ continue;
316
308
  }
317
- if (sessionInfo.url && normalizeUrl(sessionInfo.url) === normalizedUrl) {
309
+ if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
318
310
  if (verbose) {
319
311
  const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
320
312
  console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
@@ -329,6 +321,96 @@ export function hasActiveSessionForUrl(url, verbose = false) {
329
321
  return { isActive: false, sessionName: null };
330
322
  }
331
323
 
324
+ /**
325
+ * Async active-session check for command handlers.
326
+ *
327
+ * Isolation-backed sessions are refreshed through `$ --status` before they
328
+ * block a duplicate URL, so completed screen-isolated runs no longer require
329
+ * waiting for the background polling interval.
330
+ *
331
+ * @param {string} url - The GitHub URL to check
332
+ * @param {boolean} verbose - Whether to log verbose output
333
+ * @param {Object} [options] - Test/support options
334
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
335
+ * @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
336
+ */
337
+ export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
338
+ if (!url) return { isActive: false, sessionName: null };
339
+
340
+ const normalizedUrl = normalizeSessionUrl(url);
341
+
342
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
343
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
344
+ continue;
345
+ }
346
+
347
+ if (!sessionInfo.isolationBackend) {
348
+ if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
349
+ return { isActive: true, sessionName, status: null };
350
+ }
351
+ continue;
352
+ }
353
+
354
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
355
+ verbose,
356
+ statusProvider: options.statusProvider,
357
+ });
358
+ if (state.running) {
359
+ if (verbose) {
360
+ console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
361
+ }
362
+ return { isActive: true, sessionName, status: state.status || null };
363
+ }
364
+
365
+ if (verbose) {
366
+ console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
367
+ }
368
+ sessionInfo.lastKnownStatus = state.status || null;
369
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
370
+ }
371
+
372
+ if (verbose) {
373
+ console.log(`[VERBOSE] No active session found for URL ${url}`);
374
+ }
375
+ return { isActive: false, sessionName: null };
376
+ }
377
+
378
+ /**
379
+ * Refresh tracked isolation sessions and count only those that are executing.
380
+ *
381
+ * @param {boolean} verbose - Whether to log verbose output
382
+ * @param {Object} [options] - Test/support options
383
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
384
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
385
+ */
386
+ export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
387
+ const sessions = [];
388
+ const byTool = {};
389
+
390
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
391
+ if (!sessionInfo.isolationBackend) {
392
+ continue;
393
+ }
394
+
395
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
396
+ verbose,
397
+ statusProvider: options.statusProvider,
398
+ });
399
+
400
+ if (!state.running) {
401
+ sessionInfo.lastKnownStatus = state.status || null;
402
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
403
+ continue;
404
+ }
405
+
406
+ const tool = sessionInfo.tool || 'claude';
407
+ sessions.push(sessionName);
408
+ byTool[tool] = (byTool[tool] || 0) + 1;
409
+ }
410
+
411
+ return { count: sessions.length, sessions, byTool };
412
+ }
413
+
332
414
  /**
333
415
  * Get statistics about session tracking
334
416
  * @param {boolean} verbose - Whether to log verbose output
@@ -49,7 +49,8 @@ const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFro
49
49
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
50
50
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
51
51
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
52
- const { trackSession, startSessionMonitoring, hasActiveSessionForUrl } = await import('./session-monitor.lib.mjs');
52
+ const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
53
+ const { formatExecutingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
53
54
 
54
55
  const config = yargs(hideBin(process.argv))
55
56
  .usage('Usage: hive-telegram-bot [options]')
@@ -549,7 +550,7 @@ async function safeReply(ctx, text, options = {}) {
549
550
  }
550
551
  }
551
552
 
552
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null) {
553
+ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
553
554
  const { chat, message_id: msgId } = startingMessage;
554
555
  const safeEdit = async text => {
555
556
  try {
@@ -559,28 +560,33 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
559
560
  }
560
561
  };
561
562
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
562
- let result,
563
- session,
564
- extraInfo = '';
563
+ let result, session;
565
564
  if (iso) {
566
565
  session = iso.runner.generateSessionId();
567
566
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
568
567
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
569
- extraInfo = `\nšŸ”’ Isolation: \`${iso.backend}\``;
570
- if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session }, VERBOSE);
568
+ if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool }, VERBOSE);
571
569
  } else {
572
570
  result = await executeStartScreen(commandName, args);
573
571
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
574
572
  session = match ? match[1] : 'unknown';
575
573
  // Issue #1586: Track non-isolation sessions with timeout-based expiry.
576
574
  // These sessions cannot reliably detect completion (screen stays alive via
577
- // `exec bash`), so hasActiveSessionForUrl() auto-expires them after 10 min.
575
+ // `exec bash`), so active URL checks auto-expire them after 10 min.
578
576
  // This prevents accidental duplicate commands within the timeout window.
579
- if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName }, VERBOSE);
577
+ if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool }, VERBOSE);
580
578
  }
581
579
  if (result.warning) return safeEdit(`āš ļø ${result.warning}`);
582
- if (result.success) await safeEdit(`āœ… ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command started successfully!\n\nšŸ“Š Session: \`${session}\`${extraInfo}\n\n${infoBlock}\n\nšŸ”” You will receive a notification when the session finishes.`);
583
- else await safeEdit(`āŒ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
580
+ if (result.success) {
581
+ await safeEdit(
582
+ formatExecutingWorkSessionMessage({
583
+ commandName,
584
+ sessionName: session,
585
+ isolationBackend: iso?.backend || null,
586
+ infoBlock,
587
+ })
588
+ );
589
+ } else await safeEdit(`āŒ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
584
590
  }
585
591
 
586
592
  bot.command('help', async ctx => {
@@ -990,7 +996,7 @@ async function handleSolveCommand(ctx) {
990
996
  return;
991
997
  }
992
998
  // Issue #1567: Prevent concurrent sessions on the same PR/issue
993
- const activeSession = hasActiveSessionForUrl(normalizedUrl, VERBOSE);
999
+ const activeSession = await hasActiveSessionForUrlAsync(normalizedUrl, VERBOSE);
994
1000
  if (activeSession.isActive) {
995
1001
  await safeReply(ctx, `āŒ A working session is already running for this URL.\n\nURL: ${escapeMarkdown(normalizedUrl)}\nSession: \`${activeSession.sessionName}\`\n\nšŸ’” Wait for the current session to complete, or use /solve\\_stop to cancel it.`, { reply_to_message_id: ctx.message.message_id });
996
1002
  return;
@@ -1006,7 +1012,7 @@ async function handleSolveCommand(ctx) {
1006
1012
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
1007
1013
  if (check.canStart && toolQueuedCount === 0) {
1008
1014
  const startingMessage = await safeReply(ctx, `šŸš€ Starting solve command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1009
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation);
1015
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
1010
1016
  } else {
1011
1017
  const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
1012
1018
  let queueMessage = `šŸ“‹ Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
@@ -1171,7 +1177,7 @@ async function handleHiveCommand(ctx) {
1171
1177
  }
1172
1178
 
1173
1179
  const startingMessage = await safeReply(ctx, `šŸš€ Starting hive command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1174
- await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation);
1180
+ await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation, hiveTool);
1175
1181
  }
1176
1182
 
1177
1183
  bot.command(/^hive$/i, handleHiveCommand);
@@ -80,8 +80,8 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
80
80
  if (iso) {
81
81
  const sid = iso.runner.generateSessionId();
82
82
  const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
83
- if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid }, verbose);
84
- return { ...r, output: r.output || `session: ${sid}` };
83
+ if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid, tool: item.tool || 'claude' }, verbose);
84
+ return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
85
85
  }
86
86
  return fallbackCallback(item);
87
87
  };
@@ -17,9 +17,10 @@
17
17
 
18
18
  import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
19
19
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
- import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
+ import { formatDuration, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningProcesses } 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
+ import { formatExecutingWorkSessionMessage } from './work-session-formatting.lib.mjs';
23
24
 
24
25
  export const QueueItemStatus = {
25
26
  QUEUED: 'queued',
@@ -133,6 +134,8 @@ export class SolveQueue {
133
134
  this.verbose = options.verbose || false;
134
135
  this.executeCallback = options.executeCallback || null;
135
136
  this.messageUpdateCallback = options.messageUpdateCallback || null;
137
+ this.getRunningProcessesFn = options.getRunningProcesses || getRunningProcesses;
138
+ this.getRunningIsolatedSessionsFn = options.getRunningIsolatedSessions || getRunningIsolatedSessions;
136
139
 
137
140
  // Separate queues per tool type - claude tasks never block agent tasks
138
141
  // See: https://github.com/link-assistant/hive-mind/issues/1159
@@ -462,6 +465,46 @@ export class SolveQueue {
462
465
  };
463
466
  }
464
467
 
468
+ /**
469
+ * Get external processing counts from both process scanning and tracked
470
+ * isolated sessions. The displayed/accounted value is the maximum of the two
471
+ * sources so screen-isolated sessions remain visible even when the AI CLI
472
+ * process is not directly observable, while regular non-isolated runs still
473
+ * use pgrep as before.
474
+ *
475
+ * @param {string[]} tools - Tool queues to count
476
+ * @returns {Promise<{byTool: Object, processByTool: Object, isolatedByTool: Object, total: number, isolatedTotal: number, processTotal: number}>}
477
+ */
478
+ async getExternalProcessingSnapshot(tools = Object.keys(this.queues)) {
479
+ const uniqueTools = [...new Set(tools)];
480
+ const isolated = await this.getRunningIsolatedSessionsFn(this.verbose);
481
+ const isolatedByTool = isolated.byTool || {};
482
+ const processByTool = {};
483
+ const byTool = {};
484
+
485
+ await Promise.all(
486
+ uniqueTools.map(async tool => {
487
+ const result = await this.getRunningProcessesFn(tool, this.verbose);
488
+ const processCount = result?.count || 0;
489
+ const isolatedCount = isolatedByTool[tool] || 0;
490
+ processByTool[tool] = processCount;
491
+ byTool[tool] = Math.max(processCount, isolatedCount);
492
+ })
493
+ );
494
+
495
+ const processTotal = Object.values(processByTool).reduce((sum, count) => sum + count, 0);
496
+ const isolatedTotal = isolated.count || Object.values(isolatedByTool).reduce((sum, count) => sum + count, 0);
497
+
498
+ return {
499
+ byTool,
500
+ processByTool,
501
+ isolatedByTool,
502
+ total: Math.max(processTotal, isolatedTotal),
503
+ isolatedTotal,
504
+ processTotal,
505
+ };
506
+ }
507
+
465
508
  /**
466
509
  * Check if a new command can start
467
510
  *
@@ -505,16 +548,19 @@ export class SolveQueue {
505
548
  }
506
549
  }
507
550
 
508
- // Check running claude processes (this is a metric, not a blocking reason by itself)
509
- const claudeProcs = await getRunningClaudeProcesses(this.verbose);
510
- const codexProcs = await getRunningCodexProcesses(this.verbose);
511
- const agentProcs = await getRunningAgentProcesses(this.verbose);
512
- const hasRunningClaude = claudeProcs.count > 0;
513
- const hasRunningCodex = codexProcs.count > 0;
551
+ // Check running tool processes (this is a metric, not a blocking reason by itself).
552
+ // For screen-isolated sessions, use the maximum of `$ --status` executing
553
+ // counts and pgrep counts so detached sessions remain visible.
554
+ const externalProcessing = await this.getExternalProcessingSnapshot([...Object.keys(this.queues), tool]);
555
+ const claudeProcessCount = externalProcessing.byTool.claude || 0;
556
+ const codexProcessCount = externalProcessing.byTool.codex || 0;
557
+ const agentProcessCount = externalProcessing.byTool.agent || 0;
558
+ const hasRunningClaude = claudeProcessCount > 0;
559
+ const hasRunningCodex = codexProcessCount > 0;
514
560
 
515
561
  // Calculate total processing count for system resources (all tools)
516
562
  // System resources (RAM, CPU, disk) apply to all tools
517
- const totalProcessing = this.processing.size + claudeProcs.count + codexProcs.count + agentProcs.count;
563
+ const totalProcessing = this.processing.size + externalProcessing.total;
518
564
 
519
565
  // Calculate Claude-specific processing count for Claude API limits
520
566
  // Only counts Claude items in queue + external claude processes
@@ -572,10 +618,10 @@ export class SolveQueue {
572
618
  // Add claude_running info at the END (not beginning) of reasons
573
619
  // Since it's supplementary info, not the primary blocking reason
574
620
  // See: https://github.com/link-assistant/hive-mind/issues/1078
575
- reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
621
+ reasons.push(formatWaitingReason('claude_running', claudeProcessCount, 0) + ` (${claudeProcessCount} processes)`);
576
622
  }
577
623
  if (tool === 'codex' && hasRunningCodex && reasons.length > 0) {
578
- reasons.push(formatWaitingReason('codex_running', codexProcs.count, 0) + ` (${codexProcs.count} processes)`);
624
+ reasons.push(formatWaitingReason('codex_running', codexProcessCount, 0) + ` (${codexProcessCount} processes)`);
579
625
  }
580
626
 
581
627
  const canStart = reasons.length === 0 && !rejected;
@@ -595,8 +641,10 @@ export class SolveQueue {
595
641
  reason: reasons.length > 0 ? reasons.join('\n') : undefined,
596
642
  reasons,
597
643
  oneAtATime,
598
- claudeProcesses: claudeProcs.count,
599
- codexProcesses: codexProcs.count,
644
+ claudeProcesses: claudeProcessCount,
645
+ codexProcesses: codexProcessCount,
646
+ agentProcesses: agentProcessCount,
647
+ isolatedProcesses: externalProcessing.isolatedTotal,
600
648
  totalProcessing,
601
649
  claudeProcessingCount,
602
650
  codexProcessingCount,
@@ -1075,7 +1123,7 @@ export class SolveQueue {
1075
1123
  const result = await this.executeCallback(item);
1076
1124
 
1077
1125
  // Extract session name from result
1078
- let sessionName = 'unknown';
1126
+ let sessionName = result?.sessionId || 'unknown';
1079
1127
  if (result && result.output) {
1080
1128
  const sessionMatch = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/);
1081
1129
  if (sessionMatch) sessionName = sessionMatch[1];
@@ -1098,7 +1146,12 @@ export class SolveQueue {
1098
1146
  if (result.warning) {
1099
1147
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `āš ļø ${result.warning}`, { parse_mode: 'Markdown' });
1100
1148
  } else if (result.success) {
1101
- const response = `āœ… Solve command started successfully!\n\nšŸ“Š Session: \`${sessionName}\`\n\n${item.infoBlock}`;
1149
+ const response = formatExecutingWorkSessionMessage({
1150
+ commandName: item.command || 'solve',
1151
+ sessionName,
1152
+ isolationBackend: result.isolationBackend,
1153
+ infoBlock: item.infoBlock,
1154
+ });
1102
1155
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
1103
1156
  } else {
1104
1157
  const response = `āŒ Error executing solve command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``;
@@ -1177,9 +1230,8 @@ export class SolveQueue {
1177
1230
  * Format queue status for display in /limits command
1178
1231
  * Shows per-tool queue breakdown with processing counts.
1179
1232
  *
1180
- * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1181
- * This is because items transition quickly through the processing state, but the actual
1182
- * work happens in the spawned system process (claude, agent, etc.).
1233
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1234
+ * `$ --status` executing screen-isolated sessions), not queue state.
1183
1235
  *
1184
1236
  * Output format:
1185
1237
  * ```
@@ -1194,10 +1246,11 @@ export class SolveQueue {
1194
1246
  */
1195
1247
  async formatStatus() {
1196
1248
  // Always show per-tool breakdown for all known queues
1249
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1197
1250
  let message = 'Queues\n';
1198
1251
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1199
1252
  const pending = toolQueue.length;
1200
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1253
+ const processing = externalProcessing.byTool[tool] || 0;
1201
1254
  message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
1202
1255
  }
1203
1256
 
@@ -1208,9 +1261,8 @@ export class SolveQueue {
1208
1261
  * Format detailed queue status for Telegram message
1209
1262
  * Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
1210
1263
  *
1211
- * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1212
- * This is because items transition quickly through the processing state, but the actual
1213
- * work happens in the spawned system process (claude, agent, etc.).
1264
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1265
+ * `$ --status` executing screen-isolated sessions), not queue state.
1214
1266
  *
1215
1267
  * Output format:
1216
1268
  * ```
@@ -1231,16 +1283,17 @@ export class SolveQueue {
1231
1283
  */
1232
1284
  async formatDetailedStatus() {
1233
1285
  const stats = this.getStats();
1286
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1234
1287
 
1235
- // Get actual process counts for each tool queue
1236
- // The "processing" count is the number of running system processes, not queue internal state
1237
- // This ensures users see accurate counts of what's actually running
1288
+ // Get actual processing counts for each tool queue.
1289
+ // This combines pgrep with tracked isolation status so users see detached
1290
+ // screen-isolated work even when the direct AI CLI process count is lower.
1238
1291
  let message = 'šŸ“‹ *Solve Queue Status*\n\n';
1239
1292
 
1240
1293
  // Show per-tool queue breakdown with items grouped by queue
1241
1294
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1242
1295
  const pending = toolQueue.length;
1243
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1296
+ const processing = externalProcessing.byTool[tool] || 0;
1244
1297
  message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
1245
1298
 
1246
1299
  // Show first 5 queued items for this tool
@@ -1308,7 +1361,7 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1308
1361
  const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
1309
1362
  const session = match ? match[1] : null;
1310
1363
  if (session) {
1311
- trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve' });
1364
+ trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', tool: item.tool || 'claude' });
1312
1365
  }
1313
1366
  }
1314
1367
  return result;
@@ -1316,23 +1369,21 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1316
1369
  }
1317
1370
 
1318
1371
  /**
1319
- * Get count of running isolated sessions tracked via ExecutionStore
1320
- * When isolation mode is enabled, this replaces pgrep-based process detection
1321
- * for more reliable task counting.
1372
+ * Get count of tracked isolated sessions that are still executing according
1373
+ * to `$ --status`. Queue display combines this with pgrep counts using max().
1322
1374
  *
1323
1375
  * @param {boolean} verbose - Whether to log verbose output
1324
- * @returns {Promise<{count: number, sessions: string[]}>}
1376
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
1325
1377
  */
1326
1378
  export async function getRunningIsolatedSessions(verbose = false) {
1327
1379
  try {
1328
- const { getActiveSessionCount } = await import('./session-monitor.lib.mjs');
1329
- const count = getActiveSessionCount(verbose);
1330
- return { count, sessions: [] };
1380
+ const { getRunningTrackedIsolationSessions } = await import('./session-monitor.lib.mjs');
1381
+ return await getRunningTrackedIsolationSessions(verbose);
1331
1382
  } catch (error) {
1332
1383
  if (verbose) {
1333
1384
  console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
1334
1385
  }
1335
- return { count: 0, sessions: [] };
1386
+ return { count: 0, sessions: [], byTool: {} };
1336
1387
  }
1337
1388
  }
1338
1389
 
@@ -0,0 +1,72 @@
1
+ const FAILURE_STATUSES = new Set(['failed', 'cancelled', 'canceled', 'error']);
2
+
3
+ function capitalizeCommandName(commandName) {
4
+ const normalized = commandName || 'solve';
5
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);
6
+ }
7
+
8
+ function parseDateValue(value) {
9
+ if (!value) return null;
10
+ const date = value instanceof Date ? value : new Date(value);
11
+ return Number.isNaN(date.getTime()) ? null : date;
12
+ }
13
+
14
+ function normalizeExitCode(value) {
15
+ if (value === null || value === undefined) return null;
16
+ const numeric = Number(value);
17
+ return Number.isFinite(numeric) ? numeric : null;
18
+ }
19
+
20
+ export function getSessionCompletionExitCode({ exitCode = null, statusResult = null } = {}) {
21
+ const explicitExitCode = normalizeExitCode(exitCode);
22
+ if (explicitExitCode !== null) return explicitExitCode;
23
+
24
+ const statusExitCode = normalizeExitCode(statusResult?.exitCode);
25
+ if (statusExitCode !== null) return statusExitCode;
26
+
27
+ const status = String(statusResult?.status || '').toLowerCase();
28
+ if (FAILURE_STATUSES.has(status)) return 1;
29
+
30
+ return null;
31
+ }
32
+
33
+ export function formatSessionDurationSeconds(seconds) {
34
+ const totalSeconds = Math.max(0, Math.round(Number(seconds) || 0));
35
+ const days = Math.floor(totalSeconds / 86400);
36
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
37
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
38
+ const remainingSeconds = totalSeconds % 60;
39
+ const parts = [];
40
+
41
+ if (days > 0) parts.push(`${days}d`);
42
+ if (hours > 0) parts.push(`${hours}h`);
43
+ if (minutes > 0) parts.push(`${minutes}m`);
44
+ if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
45
+
46
+ return parts.join(' ');
47
+ }
48
+
49
+ export function formatExecutingWorkSessionMessage({ commandName = 'solve', sessionName = 'unknown', isolationBackend = null, infoBlock = '' } = {}) {
50
+ const isolationInfo = isolationBackend ? `\nšŸ”’ Isolation: \`${isolationBackend}\`` : '';
51
+ const details = infoBlock ? `\n\n${infoBlock}` : '';
52
+ return `ā³ ${capitalizeCommandName(commandName)} command executing...\n\nšŸ“Š Session: \`${sessionName}\`${isolationInfo}${details}`;
53
+ }
54
+
55
+ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null } = {}) {
56
+ const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
57
+ const failed = finalExitCode !== null && finalExitCode !== 0;
58
+ const statusEmoji = failed ? 'āŒ' : 'āœ…';
59
+ const statusText = failed ? `Failed (exit code: ${finalExitCode})` : 'Completed';
60
+ const isolationInfo = sessionInfo?.isolationBackend ? `\nšŸ”’ Isolation: ${sessionInfo.isolationBackend}` : '';
61
+ const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
62
+ const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
63
+ const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
64
+
65
+ let message = `${statusEmoji} *Work Session ${statusText}*\n\n`;
66
+ message += `šŸ“Š Session: \`${sessionName || 'unknown'}\`\n`;
67
+ message += `ā±ļø Duration: ${formatSessionDurationSeconds(durationSeconds)}\n`;
68
+ message += `šŸ”— URL: ${sessionInfo?.url || 'unknown'}${isolationInfo}\n\n`;
69
+ message += 'The work session has finished. You can now review the results.';
70
+
71
+ return message;
72
+ }