@link-assistant/hive-mind 2.13.3 → 2.13.5

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.
@@ -33,6 +33,7 @@ import { isDockerIsolation, sessionStartMs, resolveOomKilledState, resolveStaleE
33
33
  // Issue #2134: kill-cause diagnostics + the matching pull-request notice.
34
34
  import { buildKillCompletionSections, announceKillOnPullRequest } from './session-monitor.kill-sections.lib.mjs';
35
35
  import { runKillRecoveryForCompletion } from './session-kill-resume.lib.mjs';
36
+ import { createSessionRegistryQueries } from './session-monitor.queries.lib.mjs';
36
37
  export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
37
38
  export { DOCKER_TERMINAL_FOOTER_GRACE_MS } from './session-monitor.docker-terminal.lib.mjs';
38
39
  export { STALE_EXECUTING_MIN_AGE_MS, DOCKER_BACKEND_GONE_GRACE_MS } from './session-monitor.stale-executing.lib.mjs';
@@ -1142,275 +1143,14 @@ export async function resumeTrackedSessions(options = {}) {
1142
1143
  }
1143
1144
  return { resumed, skipped };
1144
1145
  }
1145
- /**
1146
- * Issue #1567: Check if there's an active session for a given URL.
1147
- * This prevents concurrent sessions on the same PR/issue, which causes
1148
- * iteration number jumps, duplicate "Ready to merge" comments, and other
1149
- * inconsistencies when two auto-restart-until-mergeable processes run
1150
- * simultaneously.
1151
- *
1152
- * Issue #1586: Non-isolation sessions (plain start-screen) cannot reliably
1153
- * detect completion because the screen stays alive via `exec bash`. To avoid
1154
- * permanent false positives, non-isolation sessions are auto-expired after
1155
- * NON_ISOLATION_SESSION_TIMEOUT_MS (10 minutes). Within that window they
1156
- * still block duplicate commands for the same URL, which prevents accidental
1157
- * re-runs. Isolation-backed sessions have no timeout since their completion
1158
- * is reliably detected by monitorSessions().
1159
- *
1160
- * @param {string} url - The GitHub URL to check (issue or PR URL)
1161
- * @param {boolean} verbose - Whether to log verbose output
1162
- * @returns {{isActive: boolean, sessionName: string|null}} Whether an active session exists for this URL
1163
- */
1164
- export function hasActiveSessionForUrl(url, verbose = false) {
1165
- if (!url) return { isActive: false, sessionName: null };
1166
- // Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
1167
- const normalizedUrl = normalizeSessionUrl(url);
1168
- for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1169
- // Issue #1586: Auto-expire non-isolation sessions after timeout
1170
- if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
1171
- continue;
1172
- }
1173
- if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
1174
- if (verbose) {
1175
- const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
1176
- console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
1177
- }
1178
- return { isActive: true, sessionName };
1179
- }
1180
- }
1181
- if (verbose) {
1182
- console.log(`[VERBOSE] No active session found for URL ${url}`);
1183
- }
1184
- return { isActive: false, sessionName: null };
1185
- }
1186
- const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1187
- /**
1188
- * Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
1189
- * and report whether it can be stopped by forwarding CTRL+C to the
1190
- * start-command session UUID.
1191
- *
1192
- * The `/stop <url>` Telegram flow originally consulted only the in-memory solve
1193
- * queue. But a `/solve` or `/codex` that starts immediately (queue empty)
1194
- * dispatches straight to a detached isolation session and is removed from the
1195
- * queue's `processing` Map the moment it is launched. From that point on the
1196
- * session-monitor's in-memory registry is the only place that still knows the
1197
- * URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
1198
- * even though the task was clearly running. This helper exposes that registry
1199
- * so the stop flow can recover the UUID and interrupt the session.
1200
- *
1201
- * A session is stoppable when it was launched with an isolation backend and its
1202
- * start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
1203
- * non-isolation screen sessions are reported but marked `stoppable: false`
1204
- * because `$ --stop` cannot interrupt them.
1205
- *
1206
- * @param {string} url - GitHub issue or PR URL (any normalization)
1207
- * @param {boolean} verbose - Whether to log verbose output
1208
- * @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
1209
- * isolationBackend: string|null, stoppable: boolean }|null} Match or null
1210
- */
1211
- export function findStoppableSessionByUrl(url, verbose = false) {
1212
- if (!url) return null;
1213
- const normalizedUrl = normalizeSessionUrl(url);
1214
- for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1215
- if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
1216
- continue;
1217
- }
1218
- // Issue #1586: skip expired non-isolation sessions — they are no longer running.
1219
- if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
1220
- continue;
1221
- }
1222
- // The UUID `$ --stop` expects is the start-command session id. For
1223
- // isolation sessions it is tracked either as sessionInfo.sessionId or as
1224
- // the (UUID-shaped) session key itself.
1225
- const candidateId = sessionInfo.sessionId || sessionName;
1226
- const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
1227
- const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
1228
- if (verbose) {
1229
- const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
1230
- console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
1231
- }
1232
- return {
1233
- sessionName,
1234
- sessionId,
1235
- sessionInfo,
1236
- isolationBackend: sessionInfo.isolationBackend || null,
1237
- stoppable,
1238
- };
1239
- }
1240
- if (verbose) {
1241
- console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
1242
- }
1243
- return null;
1244
- }
1245
- /**
1246
- * Async active-session check for command handlers.
1247
- *
1248
- * Isolation-backed sessions are refreshed through `$ --status` before they
1249
- * block a duplicate URL, so completed screen-isolated runs no longer require
1250
- * waiting for the background polling interval.
1251
- *
1252
- * @param {string} url - The GitHub URL to check
1253
- * @param {boolean} verbose - Whether to log verbose output
1254
- * @param {Object} [options] - Test/support options
1255
- * @param {Function} [options.statusProvider] - Optional `$ --status` provider
1256
- * @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
1257
- */
1258
- export async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
1259
- if (!url) return { isActive: false, sessionName: null };
1260
- const normalizedUrl = normalizeSessionUrl(url);
1261
- for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1262
- if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
1263
- continue;
1264
- }
1265
- if (!sessionInfo.isolationBackend) {
1266
- if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
1267
- return { isActive: true, sessionName, status: null };
1268
- }
1269
- continue;
1270
- }
1271
- const state = await getIsolationSessionState(sessionName, sessionInfo, {
1272
- verbose,
1273
- statusProvider: options.statusProvider,
1274
- });
1275
- if (state.running) {
1276
- if (verbose) {
1277
- console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
1278
- }
1279
- return { isActive: true, sessionName, status: state.status || null };
1280
- }
1281
- if (verbose) {
1282
- console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
1283
- }
1284
- sessionInfo.lastKnownStatus = state.status || null;
1285
- sessionInfo.lastKnownExitCode = state.exitCode ?? null;
1286
- }
1287
- if (verbose) {
1288
- console.log(`[VERBOSE] No active session found for URL ${url}`);
1289
- }
1290
- return { isActive: false, sessionName: null };
1291
- }
1292
- /**
1293
- * Refresh tracked isolation sessions and count only those that are executing.
1294
- *
1295
- * @param {boolean} verbose - Whether to log verbose output
1296
- * @param {Object} [options] - Test/support options
1297
- * @param {Function} [options.statusProvider] - Optional `$ --status` provider
1298
- * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
1299
- */
1300
- export async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
1301
- const sessions = [];
1302
- const byTool = {};
1303
- for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1304
- if (!sessionInfo.isolationBackend) {
1305
- continue;
1306
- }
1307
- const state = await getIsolationSessionState(sessionName, sessionInfo, {
1308
- verbose,
1309
- statusProvider: options.statusProvider,
1310
- });
1311
- if (!state.running) {
1312
- sessionInfo.lastKnownStatus = state.status || null;
1313
- sessionInfo.lastKnownExitCode = state.exitCode ?? null;
1314
- continue;
1315
- }
1316
- const tool = sessionInfo.tool || 'claude';
1317
- sessions.push(sessionName);
1318
- byTool[tool] = (byTool[tool] || 0) + 1;
1319
- }
1320
- return { count: sessions.length, sessions, byTool };
1321
- }
1322
- /**
1323
- * Return the currently-executing tracked sessions with the details needed to
1324
- * render them as a clickable list in `/queue`: the issue/PR
1325
- * `url`, the `tool`, the start time, and (for isolation sessions) the backend
1326
- * status. Both isolation and non-isolation screen sessions are included so the
1327
- * list matches what is actually executing — the queue's own in-memory
1328
- * `processing` Map is empty once a task has been dispatched to a detached
1329
- * session, which is why executing tasks were previously not listed.
1330
- *
1331
- * Liveness is determined the same way as {@link monitorSessions}: isolation
1332
- * sessions via `$ --status`, non-isolation screen sessions via a timeout window
1333
- * plus a best-effort `screen -ls` check.
1334
- *
1335
- * @param {boolean} verbose - Whether to log verbose output
1336
- * @param {Object} [options] - Test/support options
1337
- * @param {Function} [options.statusProvider] - Optional `$ --status` provider
1338
- * @param {Function} [options.screenChecker] - Optional screen-existence checker
1339
- * @returns {Promise<Array<{sessionName: string, url: string|null, tool: string, status: string|null, startTime: (Date|string|number|null), isolationBackend: (string|null)}>>}
1340
- * @see https://github.com/link-assistant/hive-mind/issues/1837
1341
- */
1342
- export async function getRunningSessionItems(verbose = false, options = {}) {
1343
- const items = [];
1344
- const screenChecker = options.screenChecker || checkScreenSessionExists;
1345
- for (const [sessionName, sessionInfo] of activeSessions.entries()) {
1346
- let running;
1347
- let status = null;
1348
- if (sessionInfo.isolationBackend) {
1349
- // Forward every injectable seam so the listing applies the same #1927
1350
- // stale-`executing` reconciliation the monitor does — a session that
1351
- // start-command still reports as `executing` but whose backend is gone (or
1352
- // whose log footer shows a kill) must not be listed as running — and so the
1353
- // whole path stays controllable from tests.
1354
- const state = await getIsolationSessionState(sessionName, sessionInfo, {
1355
- verbose,
1356
- statusProvider: options.statusProvider,
1357
- exitFromLog: options.exitFromLog,
1358
- backendAlive: options.backendAlive,
1359
- sessionRunning: options.sessionRunning,
1360
- });
1361
- running = state.running;
1362
- status = state.status || null;
1363
- if (!running) {
1364
- sessionInfo.lastKnownStatus = state.status || null;
1365
- sessionInfo.lastKnownExitCode = state.exitCode ?? null;
1366
- continue;
1367
- }
1368
- } else {
1369
- const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
1370
- const elapsed = Date.now() - startTime.getTime();
1371
- if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
1372
- if (verbose) {
1373
- console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s; excluded from running list`);
1374
- }
1375
- continue;
1376
- }
1377
- running = await screenChecker(sessionName);
1378
- if (!running) {
1379
- continue;
1380
- }
1381
- }
1382
- items.push({
1383
- sessionName,
1384
- url: sessionInfo.url || null,
1385
- tool: sessionInfo.tool || 'claude',
1386
- status,
1387
- startTime: sessionInfo.startTime || null,
1388
- isolationBackend: sessionInfo.isolationBackend || null,
1389
- });
1390
- }
1391
- if (verbose) {
1392
- console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
1393
- }
1394
- return items;
1395
- }
1396
- /**
1397
- * Get statistics about session tracking
1398
- * @param {boolean} verbose - Whether to log verbose output
1399
- * @returns {Object} Statistics object
1400
- */
1401
- export function getSessionStats(verbose = false) {
1402
- const sessions = Array.from(activeSessions.values());
1403
- const isolated = sessions.filter(s => s.isolationBackend);
1404
- if (verbose) {
1405
- console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
1406
- }
1407
- return {
1408
- total: activeSessions.size,
1409
- executing: activeSessions.size,
1410
- executed: 0,
1411
- successful: 0,
1412
- failed: 0,
1413
- isolated: isolated.length,
1414
- storageType: 'in-memory',
1415
- };
1416
- }
1146
+ // Issue #2175: the read-only registry queries live in their own module so this
1147
+ // file stays under the 1350-line early-warning threshold (see issue #1593).
1148
+ const { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats } = createSessionRegistryQueries({
1149
+ activeSessions,
1150
+ normalizeSessionUrl,
1151
+ isNonIsolationSessionActive,
1152
+ getIsolationSessionState,
1153
+ checkScreenSessionExists,
1154
+ NON_ISOLATION_SESSION_TIMEOUT_MS,
1155
+ });
1156
+ export { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats };
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Read-only queries over the session-monitor registry.
3
+ *
4
+ * Extracted from session-monitor.lib.mjs (issue #2175) so that file stays under
5
+ * the 1350-line early-warning threshold the CI file-headroom check enforces
6
+ * (long files cause concurrent PR merge conflicts — issue #1593).
7
+ *
8
+ * These functions never mutate the registry beyond refreshing the cached
9
+ * last-known status of a session that turned out to have finished, which is
10
+ * exactly what the monitor itself does on the next poll. They are exposed
11
+ * through a factory so the registry Map and the liveness helpers stay private
12
+ * to session-monitor.lib.mjs.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/2175
15
+ */
16
+
17
+ /**
18
+ * Bind the registry queries to a session-monitor instance.
19
+ *
20
+ * @param {object} deps
21
+ * @param {Map<string, object>} deps.activeSessions in-memory session registry
22
+ * @param {(url: string) => string} deps.normalizeSessionUrl
23
+ * @param {(sessionName: string, sessionInfo: object, verbose?: boolean) => boolean} deps.isNonIsolationSessionActive
24
+ * @param {(sessionName: string, sessionInfo: object, options?: object) => Promise<object>} deps.getIsolationSessionState
25
+ * @param {(sessionName: string) => Promise<boolean>} deps.checkScreenSessionExists
26
+ * @param {number} deps.NON_ISOLATION_SESSION_TIMEOUT_MS
27
+ * @returns {{hasActiveSessionForUrl: Function, findStoppableSessionByUrl: Function, hasActiveSessionForUrlAsync: Function, getRunningTrackedIsolationSessions: Function, getRunningSessionItems: Function, getSessionStats: Function}}
28
+ */
29
+ export function createSessionRegistryQueries({ activeSessions, normalizeSessionUrl, isNonIsolationSessionActive, getIsolationSessionState, checkScreenSessionExists, NON_ISOLATION_SESSION_TIMEOUT_MS }) {
30
+ /**
31
+ * Issue #1567: Check if there's an active session for a given URL.
32
+ * This prevents concurrent sessions on the same PR/issue, which causes
33
+ * iteration number jumps, duplicate "Ready to merge" comments, and other
34
+ * inconsistencies when two auto-restart-until-mergeable processes run
35
+ * simultaneously.
36
+ *
37
+ * Issue #1586: Non-isolation sessions (plain start-screen) cannot reliably
38
+ * detect completion because the screen stays alive via `exec bash`. To avoid
39
+ * permanent false positives, non-isolation sessions are auto-expired after
40
+ * NON_ISOLATION_SESSION_TIMEOUT_MS (10 minutes). Within that window they
41
+ * still block duplicate commands for the same URL, which prevents accidental
42
+ * re-runs. Isolation-backed sessions have no timeout since their completion
43
+ * is reliably detected by monitorSessions().
44
+ *
45
+ * @param {string} url - The GitHub URL to check (issue or PR URL)
46
+ * @param {boolean} verbose - Whether to log verbose output
47
+ * @returns {{isActive: boolean, sessionName: string|null}} Whether an active session exists for this URL
48
+ */
49
+ function hasActiveSessionForUrl(url, verbose = false) {
50
+ if (!url) return { isActive: false, sessionName: null };
51
+ // Normalize the URL for comparison (remove trailing slashes, fragments, etc.)
52
+ const normalizedUrl = normalizeSessionUrl(url);
53
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
54
+ // Issue #1586: Auto-expire non-isolation sessions after timeout
55
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
56
+ continue;
57
+ }
58
+ if (sessionInfo.url && normalizeSessionUrl(sessionInfo.url) === normalizedUrl) {
59
+ if (verbose) {
60
+ const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
61
+ console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
62
+ }
63
+ return { isActive: true, sessionName };
64
+ }
65
+ }
66
+ if (verbose) {
67
+ console.log(`[VERBOSE] No active session found for URL ${url}`);
68
+ }
69
+ return { isActive: false, sessionName: null };
70
+ }
71
+ const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
72
+ /**
73
+ * Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
74
+ * and report whether it can be stopped by forwarding CTRL+C to the
75
+ * start-command session UUID.
76
+ *
77
+ * The `/stop <url>` Telegram flow originally consulted only the in-memory solve
78
+ * queue. But a `/solve` or `/codex` that starts immediately (queue empty)
79
+ * dispatches straight to a detached isolation session and is removed from the
80
+ * queue's `processing` Map the moment it is launched. From that point on the
81
+ * session-monitor's in-memory registry is the only place that still knows the
82
+ * URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
83
+ * even though the task was clearly running. This helper exposes that registry
84
+ * so the stop flow can recover the UUID and interrupt the session.
85
+ *
86
+ * A session is stoppable when it was launched with an isolation backend and its
87
+ * start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
88
+ * non-isolation screen sessions are reported but marked `stoppable: false`
89
+ * because `$ --stop` cannot interrupt them.
90
+ *
91
+ * @param {string} url - GitHub issue or PR URL (any normalization)
92
+ * @param {boolean} verbose - Whether to log verbose output
93
+ * @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
94
+ * isolationBackend: string|null, stoppable: boolean }|null} Match or null
95
+ */
96
+ function findStoppableSessionByUrl(url, verbose = false) {
97
+ if (!url) return null;
98
+ const normalizedUrl = normalizeSessionUrl(url);
99
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
100
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
101
+ continue;
102
+ }
103
+ // Issue #1586: skip expired non-isolation sessions — they are no longer running.
104
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
105
+ continue;
106
+ }
107
+ // The UUID `$ --stop` expects is the start-command session id. For
108
+ // isolation sessions it is tracked either as sessionInfo.sessionId or as
109
+ // the (UUID-shaped) session key itself.
110
+ const candidateId = sessionInfo.sessionId || sessionName;
111
+ const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
112
+ const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
113
+ if (verbose) {
114
+ const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
115
+ console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
116
+ }
117
+ return {
118
+ sessionName,
119
+ sessionId,
120
+ sessionInfo,
121
+ isolationBackend: sessionInfo.isolationBackend || null,
122
+ stoppable,
123
+ };
124
+ }
125
+ if (verbose) {
126
+ console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
127
+ }
128
+ return null;
129
+ }
130
+ /**
131
+ * Async active-session check for command handlers.
132
+ *
133
+ * Isolation-backed sessions are refreshed through `$ --status` before they
134
+ * block a duplicate URL, so completed screen-isolated runs no longer require
135
+ * waiting for the background polling interval.
136
+ *
137
+ * @param {string} url - The GitHub URL to check
138
+ * @param {boolean} verbose - Whether to log verbose output
139
+ * @param {Object} [options] - Test/support options
140
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
141
+ * @returns {Promise<{isActive: boolean, sessionName: string|null, status?: string|null}>}
142
+ */
143
+ async function hasActiveSessionForUrlAsync(url, verbose = false, options = {}) {
144
+ if (!url) return { isActive: false, sessionName: null };
145
+ const normalizedUrl = normalizeSessionUrl(url);
146
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
147
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
148
+ continue;
149
+ }
150
+ if (!sessionInfo.isolationBackend) {
151
+ if (isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
152
+ return { isActive: true, sessionName, status: null };
153
+ }
154
+ continue;
155
+ }
156
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
157
+ verbose,
158
+ statusProvider: options.statusProvider,
159
+ });
160
+ if (state.running) {
161
+ if (verbose) {
162
+ console.log(`[VERBOSE] Found executing isolated session for URL ${url}: ${sessionName} (status: ${state.status || 'unknown'})`);
163
+ }
164
+ return { isActive: true, sessionName, status: state.status || null };
165
+ }
166
+ if (verbose) {
167
+ console.log(`[VERBOSE] Isolated session ${sessionName} for URL ${url} is no longer running (status: ${state.status || 'unknown'}), allowing retry while monitor sends completion`);
168
+ }
169
+ sessionInfo.lastKnownStatus = state.status || null;
170
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
171
+ }
172
+ if (verbose) {
173
+ console.log(`[VERBOSE] No active session found for URL ${url}`);
174
+ }
175
+ return { isActive: false, sessionName: null };
176
+ }
177
+ /**
178
+ * Refresh tracked isolation sessions and count only those that are executing.
179
+ *
180
+ * @param {boolean} verbose - Whether to log verbose output
181
+ * @param {Object} [options] - Test/support options
182
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
183
+ * @returns {Promise<{count: number, sessions: string[], byTool: Object}>}
184
+ */
185
+ async function getRunningTrackedIsolationSessions(verbose = false, options = {}) {
186
+ const sessions = [];
187
+ const byTool = {};
188
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
189
+ if (!sessionInfo.isolationBackend) {
190
+ continue;
191
+ }
192
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
193
+ verbose,
194
+ statusProvider: options.statusProvider,
195
+ });
196
+ if (!state.running) {
197
+ sessionInfo.lastKnownStatus = state.status || null;
198
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
199
+ continue;
200
+ }
201
+ const tool = sessionInfo.tool || 'claude';
202
+ sessions.push(sessionName);
203
+ byTool[tool] = (byTool[tool] || 0) + 1;
204
+ }
205
+ return { count: sessions.length, sessions, byTool };
206
+ }
207
+ /**
208
+ * Return the currently-executing tracked sessions with the details needed to
209
+ * render them as a clickable list in `/queue`: the issue/PR
210
+ * `url`, the `tool`, the start time, and (for isolation sessions) the backend
211
+ * status. Both isolation and non-isolation screen sessions are included so the
212
+ * list matches what is actually executing — the queue's own in-memory
213
+ * `processing` Map is empty once a task has been dispatched to a detached
214
+ * session, which is why executing tasks were previously not listed.
215
+ *
216
+ * Liveness is determined the same way as {@link monitorSessions}: isolation
217
+ * sessions via `$ --status`, non-isolation screen sessions via a timeout window
218
+ * plus a best-effort `screen -ls` check.
219
+ *
220
+ * @param {boolean} verbose - Whether to log verbose output
221
+ * @param {Object} [options] - Test/support options
222
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
223
+ * @param {Function} [options.screenChecker] - Optional screen-existence checker
224
+ * @returns {Promise<Array<{sessionName: string, url: string|null, tool: string, status: string|null, startTime: (Date|string|number|null), isolationBackend: (string|null)}>>}
225
+ * @see https://github.com/link-assistant/hive-mind/issues/1837
226
+ */
227
+ async function getRunningSessionItems(verbose = false, options = {}) {
228
+ const items = [];
229
+ const screenChecker = options.screenChecker || checkScreenSessionExists;
230
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
231
+ let running;
232
+ let status = null;
233
+ if (sessionInfo.isolationBackend) {
234
+ // Forward every injectable seam so the listing applies the same #1927
235
+ // stale-`executing` reconciliation the monitor does — a session that
236
+ // start-command still reports as `executing` but whose backend is gone (or
237
+ // whose log footer shows a kill) must not be listed as running — and so the
238
+ // whole path stays controllable from tests.
239
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
240
+ verbose,
241
+ statusProvider: options.statusProvider,
242
+ exitFromLog: options.exitFromLog,
243
+ backendAlive: options.backendAlive,
244
+ sessionRunning: options.sessionRunning,
245
+ });
246
+ running = state.running;
247
+ status = state.status || null;
248
+ if (!running) {
249
+ sessionInfo.lastKnownStatus = state.status || null;
250
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
251
+ continue;
252
+ }
253
+ } else {
254
+ const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
255
+ const elapsed = Date.now() - startTime.getTime();
256
+ if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
257
+ if (verbose) {
258
+ console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s; excluded from running list`);
259
+ }
260
+ continue;
261
+ }
262
+ running = await screenChecker(sessionName);
263
+ if (!running) {
264
+ continue;
265
+ }
266
+ }
267
+ items.push({
268
+ sessionName,
269
+ url: sessionInfo.url || null,
270
+ tool: sessionInfo.tool || 'claude',
271
+ status,
272
+ startTime: sessionInfo.startTime || null,
273
+ isolationBackend: sessionInfo.isolationBackend || null,
274
+ });
275
+ }
276
+ if (verbose) {
277
+ console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
278
+ }
279
+ return items;
280
+ }
281
+ /**
282
+ * Get statistics about session tracking
283
+ * @param {boolean} verbose - Whether to log verbose output
284
+ * @returns {Object} Statistics object
285
+ */
286
+ function getSessionStats(verbose = false) {
287
+ const sessions = Array.from(activeSessions.values());
288
+ const isolated = sessions.filter(s => s.isolationBackend);
289
+ if (verbose) {
290
+ console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
291
+ }
292
+ return {
293
+ total: activeSessions.size,
294
+ executing: activeSessions.size,
295
+ executed: 0,
296
+ successful: 0,
297
+ failed: 0,
298
+ isolated: isolated.length,
299
+ storageType: 'in-memory',
300
+ };
301
+ }
302
+
303
+ return { hasActiveSessionForUrl, findStoppableSessionByUrl, hasActiveSessionForUrlAsync, getRunningTrackedIsolationSessions, getRunningSessionItems, getSessionStats };
304
+ }