@link-assistant/hive-mind 1.56.7 → 1.56.8

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,12 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.56.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 05a3e42: Fix CI/CD change detection for pull request synchronize events so metadata-only updates skip expensive test jobs while still reporting completed checks.
8
+ - 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.
9
+
3
10
  ## 1.56.7
4
11
 
5
12
  ### 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.8",
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`);
@@ -28,12 +28,6 @@ async function getIsolationRunner() {
28
28
  }
29
29
  return _isolationRunner;
30
30
  }
31
- // Legacy accessor for querySessionStatus
32
- async function getQuerySessionStatus() {
33
- const mod = await getIsolationRunner();
34
- return mod.querySessionStatus;
35
- }
36
-
37
31
  // In-memory session store
38
32
  const activeSessions = new Map();
39
33
 
@@ -65,51 +59,6 @@ export async function checkScreenSessionExists(sessionName) {
65
59
  }
66
60
  }
67
61
 
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
62
  /**
114
63
  * Track a new session for completion monitoring
115
64
  *
@@ -172,6 +121,67 @@ function completeSession(sessionName, exitCode = 0, verbose = false) {
172
121
  }
173
122
  }
174
123
 
124
+ function normalizeSessionUrl(url) {
125
+ return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
126
+ }
127
+
128
+ function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
129
+ const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
130
+ const elapsed = Date.now() - startTime.getTime();
131
+ if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
132
+ if (verbose) {
133
+ 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`);
134
+ }
135
+ activeSessions.delete(sessionName);
136
+ return false;
137
+ }
138
+ if (verbose) {
139
+ const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
140
+ console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
141
+ }
142
+ return true;
143
+ }
144
+
145
+ async function getIsolationSessionState(sessionName, sessionInfo, options = {}) {
146
+ const { verbose = false, statusProvider = null } = options;
147
+ const sessionId = sessionInfo.sessionId || sessionName;
148
+
149
+ try {
150
+ const runner = await getIsolationRunner();
151
+ const statusResult = statusProvider ? await statusProvider(sessionId, sessionInfo) : await runner.querySessionStatus(sessionId, verbose);
152
+
153
+ if (statusResult?.exists && statusResult.status) {
154
+ if (runner.isExecutingSessionStatus(statusResult.status)) {
155
+ return { running: true, exitCode: null, status: statusResult.status, statusResult };
156
+ }
157
+ if (runner.isTerminalSessionStatus(statusResult.status)) {
158
+ return {
159
+ running: false,
160
+ exitCode: statusResult.exitCode !== undefined ? statusResult.exitCode : null,
161
+ status: statusResult.status,
162
+ statusResult,
163
+ };
164
+ }
165
+ }
166
+
167
+ const running = await runner.isSessionRunning(sessionId, {
168
+ backend: sessionInfo.isolationBackend,
169
+ verbose,
170
+ });
171
+ return {
172
+ running,
173
+ exitCode: running ? null : (statusResult?.exitCode ?? null),
174
+ status: statusResult?.status || null,
175
+ statusResult,
176
+ };
177
+ } catch (error) {
178
+ if (verbose) {
179
+ console.error(`[VERBOSE] Error refreshing isolated session ${sessionId}: ${error.message}`);
180
+ }
181
+ return { running: false, exitCode: null, status: null, statusResult: null };
182
+ }
183
+ }
184
+
175
185
  /**
176
186
  * Monitor active sessions and send notifications when they complete
177
187
  * @param {Object} bot - Telegraf bot instance for sending messages
@@ -193,15 +203,12 @@ export async function monitorSessions(bot, verbose = false) {
193
203
  let exitCode = null;
194
204
 
195
205
  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
- }
206
+ // Isolation mode: use $ --status, with screen -ls only as a fallback
207
+ // when the status record is unavailable. Terminal $ statuses are
208
+ // authoritative so completed screen sessions do not stay blocked.
209
+ const state = await getIsolationSessionState(sessionName, sessionInfo, { verbose });
210
+ stillRunning = state.running;
211
+ exitCode = state.exitCode;
205
212
  } else {
206
213
  // Issue #1586: Non-isolation screen sessions cannot reliably detect
207
214
  // completion because start-screen keeps the screen alive via `exec bash`.
@@ -294,27 +301,14 @@ export function hasActiveSessionForUrl(url, verbose = false) {
294
301
  if (!url) return { isActive: false, sessionName: null };
295
302
 
296
303
  // Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
297
- const normalizeUrl = u => u.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
298
- const normalizedUrl = normalizeUrl(url);
304
+ const normalizedUrl = normalizeSessionUrl(url);
299
305
 
300
306
  for (const [sessionName, sessionInfo] of activeSessions.entries()) {
301
307
  // 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
- }
308
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
309
+ continue;
316
310
  }
317
- if (sessionInfo.url && normalizeUrl(sessionInfo.url) === normalizedUrl) {
311
+ if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
318
312
  if (verbose) {
319
313
  const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
320
314
  console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
@@ -329,6 +323,96 @@ export function hasActiveSessionForUrl(url, verbose = false) {
329
323
  return { isActive: false, sessionName: null };
330
324
  }
331
325
 
326
+ /**
327
+ * Async active-session check for command handlers.
328
+ *
329
+ * Isolation-backed sessions are refreshed through `$ --status` before they
330
+ * block a duplicate URL, so completed screen-isolated runs no longer require
331
+ * waiting for the background polling interval.
332
+ *
333
+ * @param {string} url - The GitHub URL to check
334
+ * @param {boolean} verbose - Whether to log verbose output
335
+ * @param {Object} [options] - Test/support options
336
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
337
+ * @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
338
+ */
339
+ export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
340
+ if (!url) return { isActive: false, sessionName: null };
341
+
342
+ const normalizedUrl = normalizeSessionUrl(url);
343
+
344
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
345
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
346
+ continue;
347
+ }
348
+
349
+ if (!sessionInfo.isolationBackend) {
350
+ if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
351
+ return { isActive: true, sessionName, status: null };
352
+ }
353
+ continue;
354
+ }
355
+
356
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
357
+ verbose,
358
+ statusProvider: options.statusProvider,
359
+ });
360
+ if (state.running) {
361
+ if (verbose) {
362
+ console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
363
+ }
364
+ return { isActive: true, sessionName, status: state.status || null };
365
+ }
366
+
367
+ if (verbose) {
368
+ console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
369
+ }
370
+ sessionInfo.lastKnownStatus = state.status || null;
371
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
372
+ }
373
+
374
+ if (verbose) {
375
+ console.log(`[VERBOSE] No active session found for URL ${url}`);
376
+ }
377
+ return { isActive: false, sessionName: null };
378
+ }
379
+
380
+ /**
381
+ * Refresh tracked isolation sessions and count only those that are executing.
382
+ *
383
+ * @param {boolean} verbose - Whether to log verbose output
384
+ * @param {Object} [options] - Test/support options
385
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
386
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
387
+ */
388
+ export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
389
+ const sessions = [];
390
+ const byTool = {};
391
+
392
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
393
+ if (!sessionInfo.isolationBackend) {
394
+ continue;
395
+ }
396
+
397
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
398
+ verbose,
399
+ statusProvider: options.statusProvider,
400
+ });
401
+
402
+ if (!state.running) {
403
+ sessionInfo.lastKnownStatus = state.status || null;
404
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
405
+ continue;
406
+ }
407
+
408
+ const tool = sessionInfo.tool || 'claude';
409
+ sessions.push(sessionName);
410
+ byTool[tool] = (byTool[tool] || 0) + 1;
411
+ }
412
+
413
+ return { count: sessions.length, sessions, byTool };
414
+ }
415
+
332
416
  /**
333
417
  * Get statistics about session tracking
334
418
  * @param {boolean} verbose - Whether to log verbose output
@@ -49,7 +49,7 @@ 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
53
 
54
54
  const config = yargs(hideBin(process.argv))
55
55
  .usage('Usage: hive-telegram-bot [options]')
@@ -549,7 +549,7 @@ async function safeReply(ctx, text, options = {}) {
549
549
  }
550
550
  }
551
551
 
552
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null) {
552
+ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
553
553
  const { chat, message_id: msgId } = startingMessage;
554
554
  const safeEdit = async text => {
555
555
  try {
@@ -567,19 +567,19 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
567
567
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
568
568
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
569
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);
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, tool }, VERBOSE);
571
571
  } else {
572
572
  result = await executeStartScreen(commandName, args);
573
573
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
574
574
  session = match ? match[1] : 'unknown';
575
575
  // Issue #1586: Track non-isolation sessions with timeout-based expiry.
576
576
  // These sessions cannot reliably detect completion (screen stays alive via
577
- // `exec bash`), so hasActiveSessionForUrl() auto-expires them after 10 min.
577
+ // `exec bash`), so active URL checks auto-expire them after 10 min.
578
578
  // 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);
579
+ if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool }, VERBOSE);
580
580
  }
581
581
  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.`);
582
+ if (result.success) await safeEdit(`🔄 ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command executing...\n\nStatus: \`Executing...\`\n📊 Session: \`${session}\`${extraInfo}\n\n${infoBlock}\n\n🔔 This message will update when the session finishes.`);
583
583
  else await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
584
584
  }
585
585
 
@@ -990,7 +990,7 @@ async function handleSolveCommand(ctx) {
990
990
  return;
991
991
  }
992
992
  // Issue #1567: Prevent concurrent sessions on the same PR/issue
993
- const activeSession = hasActiveSessionForUrl(normalizedUrl, VERBOSE);
993
+ const activeSession = await hasActiveSessionForUrlAsync(normalizedUrl, VERBOSE);
994
994
  if (activeSession.isActive) {
995
995
  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
996
  return;
@@ -1006,7 +1006,7 @@ async function handleSolveCommand(ctx) {
1006
1006
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
1007
1007
  if (check.canStart && toolQueuedCount === 0) {
1008
1008
  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);
1009
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
1010
1010
  } else {
1011
1011
  const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
1012
1012
  let queueMessage = `📋 Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
@@ -1171,7 +1171,7 @@ async function handleHiveCommand(ctx) {
1171
1171
  }
1172
1172
 
1173
1173
  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);
1174
+ await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, effectiveHiveIsolation, hiveTool);
1175
1175
  }
1176
1176
 
1177
1177
  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,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, 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
23
 
@@ -133,6 +133,8 @@ export class SolveQueue {
133
133
  this.verbose = options.verbose || false;
134
134
  this.executeCallback = options.executeCallback || null;
135
135
  this.messageUpdateCallback = options.messageUpdateCallback || null;
136
+ this.getRunningProcessesFn = options.getRunningProcesses || getRunningProcesses;
137
+ this.getRunningIsolatedSessionsFn = options.getRunningIsolatedSessions || getRunningIsolatedSessions;
136
138
 
137
139
  // Separate queues per tool type - claude tasks never block agent tasks
138
140
  // See: https://github.com/link-assistant/hive-mind/issues/1159
@@ -462,6 +464,46 @@ export class SolveQueue {
462
464
  };
463
465
  }
464
466
 
467
+ /**
468
+ * Get external processing counts from both process scanning and tracked
469
+ * isolated sessions. The displayed/accounted value is the maximum of the two
470
+ * sources so screen-isolated sessions remain visible even when the AI CLI
471
+ * process is not directly observable, while regular non-isolated runs still
472
+ * use pgrep as before.
473
+ *
474
+ * @param {string[]} tools - Tool queues to count
475
+ * @returns {Promise<{byTool: Object, processByTool: Object, isolatedByTool: Object, total: number, isolatedTotal: number, processTotal: number}>}
476
+ */
477
+ async getExternalProcessingSnapshot(tools = Object.keys(this.queues)) {
478
+ const uniqueTools = [...new Set(tools)];
479
+ const isolated = await this.getRunningIsolatedSessionsFn(this.verbose);
480
+ const isolatedByTool = isolated.byTool || {};
481
+ const processByTool = {};
482
+ const byTool = {};
483
+
484
+ await Promise.all(
485
+ uniqueTools.map(async tool => {
486
+ const result = await this.getRunningProcessesFn(tool, this.verbose);
487
+ const processCount = result?.count || 0;
488
+ const isolatedCount = isolatedByTool[tool] || 0;
489
+ processByTool[tool] = processCount;
490
+ byTool[tool] = Math.max(processCount, isolatedCount);
491
+ })
492
+ );
493
+
494
+ const processTotal = Object.values(processByTool).reduce((sum, count) => sum + count, 0);
495
+ const isolatedTotal = isolated.count || Object.values(isolatedByTool).reduce((sum, count) => sum + count, 0);
496
+
497
+ return {
498
+ byTool,
499
+ processByTool,
500
+ isolatedByTool,
501
+ total: Math.max(processTotal, isolatedTotal),
502
+ isolatedTotal,
503
+ processTotal,
504
+ };
505
+ }
506
+
465
507
  /**
466
508
  * Check if a new command can start
467
509
  *
@@ -505,16 +547,19 @@ export class SolveQueue {
505
547
  }
506
548
  }
507
549
 
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;
550
+ // Check running tool processes (this is a metric, not a blocking reason by itself).
551
+ // For screen-isolated sessions, use the maximum of `$ --status` executing
552
+ // counts and pgrep counts so detached sessions remain visible.
553
+ const externalProcessing = await this.getExternalProcessingSnapshot([...Object.keys(this.queues), tool]);
554
+ const claudeProcessCount = externalProcessing.byTool.claude || 0;
555
+ const codexProcessCount = externalProcessing.byTool.codex || 0;
556
+ const agentProcessCount = externalProcessing.byTool.agent || 0;
557
+ const hasRunningClaude = claudeProcessCount > 0;
558
+ const hasRunningCodex = codexProcessCount > 0;
514
559
 
515
560
  // Calculate total processing count for system resources (all tools)
516
561
  // System resources (RAM, CPU, disk) apply to all tools
517
- const totalProcessing = this.processing.size + claudeProcs.count + codexProcs.count + agentProcs.count;
562
+ const totalProcessing = this.processing.size + externalProcessing.total;
518
563
 
519
564
  // Calculate Claude-specific processing count for Claude API limits
520
565
  // Only counts Claude items in queue + external claude processes
@@ -572,10 +617,10 @@ export class SolveQueue {
572
617
  // Add claude_running info at the END (not beginning) of reasons
573
618
  // Since it's supplementary info, not the primary blocking reason
574
619
  // See: https://github.com/link-assistant/hive-mind/issues/1078
575
- reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
620
+ reasons.push(formatWaitingReason('claude_running', claudeProcessCount, 0) + ` (${claudeProcessCount} processes)`);
576
621
  }
577
622
  if (tool === 'codex' && hasRunningCodex && reasons.length > 0) {
578
- reasons.push(formatWaitingReason('codex_running', codexProcs.count, 0) + ` (${codexProcs.count} processes)`);
623
+ reasons.push(formatWaitingReason('codex_running', codexProcessCount, 0) + ` (${codexProcessCount} processes)`);
579
624
  }
580
625
 
581
626
  const canStart = reasons.length === 0 && !rejected;
@@ -595,8 +640,10 @@ export class SolveQueue {
595
640
  reason: reasons.length > 0 ? reasons.join('\n') : undefined,
596
641
  reasons,
597
642
  oneAtATime,
598
- claudeProcesses: claudeProcs.count,
599
- codexProcesses: codexProcs.count,
643
+ claudeProcesses: claudeProcessCount,
644
+ codexProcesses: codexProcessCount,
645
+ agentProcesses: agentProcessCount,
646
+ isolatedProcesses: externalProcessing.isolatedTotal,
600
647
  totalProcessing,
601
648
  claudeProcessingCount,
602
649
  codexProcessingCount,
@@ -1075,7 +1122,7 @@ export class SolveQueue {
1075
1122
  const result = await this.executeCallback(item);
1076
1123
 
1077
1124
  // Extract session name from result
1078
- let sessionName = 'unknown';
1125
+ let sessionName = result?.sessionId || 'unknown';
1079
1126
  if (result && result.output) {
1080
1127
  const sessionMatch = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/);
1081
1128
  if (sessionMatch) sessionName = sessionMatch[1];
@@ -1098,7 +1145,8 @@ export class SolveQueue {
1098
1145
  if (result.warning) {
1099
1146
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `⚠️ ${result.warning}`, { parse_mode: 'Markdown' });
1100
1147
  } else if (result.success) {
1101
- const response = `✅ Solve command started successfully!\n\n📊 Session: \`${sessionName}\`\n\n${item.infoBlock}`;
1148
+ const isolationInfo = result.isolationBackend ? `\n🔒 Isolation: \`${result.isolationBackend}\`` : '';
1149
+ const response = `🔄 Solve command executing...\n\nStatus: \`Executing...\`\n📊 Session: \`${sessionName}\`${isolationInfo}\n\n${item.infoBlock}\n\n🔔 This message will update when the session finishes.`;
1102
1150
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
1103
1151
  } else {
1104
1152
  const response = `❌ Error executing solve command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``;
@@ -1177,9 +1225,8 @@ export class SolveQueue {
1177
1225
  * Format queue status for display in /limits command
1178
1226
  * Shows per-tool queue breakdown with processing counts.
1179
1227
  *
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.).
1228
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1229
+ * `$ --status` executing screen-isolated sessions), not queue state.
1183
1230
  *
1184
1231
  * Output format:
1185
1232
  * ```
@@ -1194,10 +1241,11 @@ export class SolveQueue {
1194
1241
  */
1195
1242
  async formatStatus() {
1196
1243
  // Always show per-tool breakdown for all known queues
1244
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1197
1245
  let message = 'Queues\n';
1198
1246
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1199
1247
  const pending = toolQueue.length;
1200
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1248
+ const processing = externalProcessing.byTool[tool] || 0;
1201
1249
  message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
1202
1250
  }
1203
1251
 
@@ -1208,9 +1256,8 @@ export class SolveQueue {
1208
1256
  * Format detailed queue status for Telegram message
1209
1257
  * Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
1210
1258
  *
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.).
1259
+ * Processing count = max(actual AI CLI processes via pgrep, tracked
1260
+ * `$ --status` executing screen-isolated sessions), not queue state.
1214
1261
  *
1215
1262
  * Output format:
1216
1263
  * ```
@@ -1231,16 +1278,17 @@ export class SolveQueue {
1231
1278
  */
1232
1279
  async formatDetailedStatus() {
1233
1280
  const stats = this.getStats();
1281
+ const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1234
1282
 
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
1283
+ // Get actual processing counts for each tool queue.
1284
+ // This combines pgrep with tracked isolation status so users see detached
1285
+ // screen-isolated work even when the direct AI CLI process count is lower.
1238
1286
  let message = '📋 *Solve Queue Status*\n\n';
1239
1287
 
1240
1288
  // Show per-tool queue breakdown with items grouped by queue
1241
1289
  for (const [tool, toolQueue] of Object.entries(this.queues)) {
1242
1290
  const pending = toolQueue.length;
1243
- const processing = (await getRunningProcesses(tool, this.verbose)).count;
1291
+ const processing = externalProcessing.byTool[tool] || 0;
1244
1292
  message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
1245
1293
 
1246
1294
  // Show first 5 queued items for this tool
@@ -1308,7 +1356,7 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1308
1356
  const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
1309
1357
  const session = match ? match[1] : null;
1310
1358
  if (session) {
1311
- trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve' });
1359
+ trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', tool: item.tool || 'claude' });
1312
1360
  }
1313
1361
  }
1314
1362
  return result;
@@ -1316,23 +1364,21 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1316
1364
  }
1317
1365
 
1318
1366
  /**
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.
1367
+ * Get count of tracked isolated sessions that are still executing according
1368
+ * to `$ --status`. Queue display combines this with pgrep counts using max().
1322
1369
  *
1323
1370
  * @param {boolean} verbose - Whether to log verbose output
1324
- * @returns {Promise<{count: number, sessions: string[]}>}
1371
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
1325
1372
  */
1326
1373
  export async function getRunningIsolatedSessions(verbose = false) {
1327
1374
  try {
1328
- const { getActiveSessionCount } = await import('./session-monitor.lib.mjs');
1329
- const count = getActiveSessionCount(verbose);
1330
- return { count, sessions: [] };
1375
+ const { getRunningTrackedIsolationSessions } = await import('./session-monitor.lib.mjs');
1376
+ return await getRunningTrackedIsolationSessions(verbose);
1331
1377
  } catch (error) {
1332
1378
  if (verbose) {
1333
1379
  console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
1334
1380
  }
1335
- return { count: 0, sessions: [] };
1381
+ return { count: 0, sessions: [], byTool: {} };
1336
1382
  }
1337
1383
  }
1338
1384