@link-assistant/hive-mind 2.13.4 → 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.
package/src/hive.mjs CHANGED
@@ -103,125 +103,10 @@ if (isRunningDirectly) {
103
103
  const commandName = process.argv[1] ? process.argv[1].split('/').pop() : '';
104
104
  const isLocalScript = commandName.endsWith('.mjs');
105
105
  const solveCommand = isLocalScript ? './solve.mjs' : 'solve';
106
- /**
107
- * Fallback function to fetch issues from organization/user repositories
108
- * when search API hits rate limits
109
- * @param {string} owner - Organization or user name
110
- * @param {string} scope - 'organization' or 'user'
111
- * @param {string} monitorTag - Label to filter by (optional)
112
- * @param {boolean} allIssues - Whether to fetch all issues or only labeled ones
113
- * @returns {Promise<Array>} Array of issues
114
- */
115
- async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
116
- try {
117
- await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
118
- // Strategy 1: Try GraphQL approach first (faster but has limitations)
119
- // Only try GraphQL for "all issues" mode, not for labeled issues
120
- if (fetchAllIssues) {
121
- const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
122
- if (graphqlResult.success) {
123
- await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
124
- return graphqlResult.issues;
125
- }
126
- }
127
- // Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
128
- await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
129
-
130
- // Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
131
- let repoListCmd;
132
- if (scope === 'organization') {
133
- repoListCmd = `gh api orgs/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
134
- } else {
135
- repoListCmd = `gh api users/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
136
- }
137
- await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
138
- await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
139
- // Add delay for rate limiting
140
- await new Promise(resolve => setTimeout(resolve, 2000));
141
- // #1756: route through execGhWithRetry for transient 5xx + rate-limit
142
- const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
143
- execOptions: { encoding: 'utf8', env: process.env },
144
- label: `gh api ${scope} repos (paginated)`,
145
- });
146
- // Parse the output line by line, as gh api with --jq outputs one JSON object per line
147
- const repoLines = repoOutput
148
- .trim()
149
- .split('\n')
150
- .filter(line => line.trim());
151
- const allRepositories = repoLines.map(line => JSON.parse(line));
152
- await log(` 📊 Found ${allRepositories.length} repositories`);
153
- // Filter repositories to only include those owned by the target user/org
154
- const ownedRepositories = allRepositories.filter(repo => {
155
- const repoOwner = repo.owner?.login || repo.owner;
156
- return repoOwner === owner;
157
- });
158
- const unownedCount = allRepositories.length - ownedRepositories.length;
159
- if (unownedCount > 0) {
160
- await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
161
- }
162
- // Filter out archived repositories from owned repositories
163
- const repositories = ownedRepositories.filter(repo => !repo.isArchived);
164
- const archivedCount = ownedRepositories.length - repositories.length;
165
- if (archivedCount > 0) {
166
- await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
167
- }
168
- await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
169
- let collectedIssues = [];
170
- let processedRepos = 0;
171
- // Process repositories in batches to avoid overwhelming the API
172
- for (const repo of repositories) {
173
- try {
174
- const repoName = repo.name;
175
- const ownerName = repo.owner?.login || owner;
176
- await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
177
- // Build the appropriate issue list command
178
- let issueCmd;
179
- if (fetchAllIssues) {
180
- issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --json url,title,number,createdAt`;
181
- } else {
182
- issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open --label "${monitorTag}" --json url,title,number,createdAt`;
183
- }
184
- // Add delay between repository requests
185
- await new Promise(resolve => setTimeout(resolve, 1000));
186
- const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
187
- // Add repository information to each issue
188
- const issuesWithRepo = repoIssues.map(issue => ({
189
- ...issue,
190
- repository: {
191
- name: repoName,
192
- owner: { login: ownerName },
193
- },
194
- }));
195
- collectedIssues.push(...issuesWithRepo);
196
- processedRepos++;
197
- if (issuesWithRepo.length > 0) {
198
- await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
199
- }
200
- } catch (repoError) {
201
- reportError(repoError, {
202
- context: 'fetchIssuesFromRepositories',
203
- repo: repo.name,
204
- operation: 'fetch_repo_issues',
205
- });
206
- await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, {
207
- verbose: true,
208
- });
209
- // Continue with other repositories
210
- }
211
- }
212
- await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
213
- return collectedIssues;
214
- } catch (error) {
215
- reportError(error, {
216
- context: 'fetchIssuesFromRepositories',
217
- owner,
218
- scope,
219
- operation: 'repository_fallback',
220
- });
221
- await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
222
- return [];
223
- }
224
- }
106
+ // Repository-by-repository fallback lives in its own module so hive.mjs stays
107
+ // under the 1350-line early-warning threshold (issue #2175, warning from #1593).
108
+ const repositoryFallbackLib = await import('./hive.repository-fallback.lib.mjs');
109
+ const fetchIssuesFromRepositories = repositoryFallbackLib.createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError });
225
110
  // Configure command line arguments - GitHub URL as positional argument
226
111
  const rawArgs = normalizeCliArgs(hideBin(process.argv));
227
112
  // Use .parse() instead of .argv to ensure .strict() mode works correctly
@@ -1398,37 +1283,10 @@ if (isRunningDirectly) {
1398
1283
  delegateSignalHandling(true);
1399
1284
  process.on('SIGINT', () => gracefulShutdown('interrupt'));
1400
1285
  process.on('SIGTERM', () => gracefulShutdown('termination'));
1401
- // Check system resources (disk space and RAM) before starting monitoring (skip in dry-run mode)
1402
- if (argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) {
1403
- await log('⏩ Skipping system resource check (dry-run mode or skip-tool-connection-check enabled)', {
1404
- verbose: true,
1405
- });
1406
- await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', {
1407
- verbose: true,
1408
- });
1409
- } else {
1410
- // Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
1411
- // start, and report an exhausted disk as the environment condition it is (exit 75) instead of
1412
- // a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
1413
- // directly, which skips the log-flushing safeExit path and printed no actionable reason.
1414
- const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
1415
- const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
1416
- if (!startupDiskGuard.ok) {
1417
- await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
1418
- await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
1419
- await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
1420
- }
1421
- const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
1422
- if (!systemCheck.success) {
1423
- await safeExit(1, 'System resource check failed');
1424
- }
1425
- // Validate the selected AI tool connection before starting monitoring with the same model that will be used
1426
- const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
1427
- if (!isToolConnected) {
1428
- await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
1429
- await safeExit(1, 'Error occurred');
1430
- }
1431
- }
1286
+ // Pre-flight checks live in hive.startup-checks.lib.mjs so hive.mjs stays under
1287
+ // the 1350-line early-warning threshold (issue #2175, warning from #1593).
1288
+ const startupChecksLib = await import('./hive.startup-checks.lib.mjs');
1289
+ await startupChecksLib.runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE });
1432
1290
  // Wrap monitor function with Sentry error tracking
1433
1291
  const monitorWithSentry = !argv.sentry ? monitor : withSentry(monitor, 'hive.monitor', 'command');
1434
1292
  // Start monitoring
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Repository-by-repository issue fetching fallback for the hive.
3
+ *
4
+ * Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
5
+ * 1350-line early-warning threshold that protects concurrent merges (#1593).
6
+ * The logic is unchanged; it now takes its collaborators as parameters instead
7
+ * of closing over the dynamically imported bindings in hive.mjs, which also
8
+ * makes it unit-testable (see tests/hive-repository-fallback-2175.test.mjs).
9
+ *
10
+ * Used when GitHub's search API is rate-limited: rather than giving up, every
11
+ * repository owned by the org/user is listed and queried directly.
12
+ */
13
+
14
+ /**
15
+ * Build the repository-fallback fetcher with its dependencies bound.
16
+ *
17
+ * @param {object} deps
18
+ * @param {Function} deps.log
19
+ * @param {Function} deps.cleanErrorMessage
20
+ * @param {Function} deps.tryFetchIssuesWithGraphQL
21
+ * @param {Function} deps.execGhWithRetry
22
+ * @param {Function} deps.fetchAllIssuesWithPagination
23
+ * @param {Function} deps.reportError
24
+ * @param {(ms: number) => Promise<void>} [deps.sleeper] delay between API calls
25
+ * @returns {(owner: string, scope: string, monitorTag?: string, fetchAllIssues?: boolean) => Promise<Array>}
26
+ */
27
+ export function createRepositoryIssueFetcher({ log, cleanErrorMessage, tryFetchIssuesWithGraphQL, execGhWithRetry, fetchAllIssuesWithPagination, reportError, sleeper = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
28
+ /**
29
+ * Fallback function to fetch issues from organization/user repositories
30
+ * when search API hits rate limits
31
+ * @param {string} owner - Organization or user name
32
+ * @param {string} scope - 'organization' or 'user'
33
+ * @param {string} monitorTag - Label to filter by (optional)
34
+ * @param {boolean} fetchAllIssues - Whether to fetch all issues or only labeled ones
35
+ * @returns {Promise<Array>} Array of issues
36
+ */
37
+ return async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
38
+ try {
39
+ await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
40
+ // Strategy 1: Try GraphQL approach first (faster but has limitations)
41
+ // Only try GraphQL for "all issues" mode, not for labeled issues
42
+ if (fetchAllIssues) {
43
+ const graphqlResult = await tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage);
44
+ if (graphqlResult.success) {
45
+ await log(` ✅ GraphQL approach successful: ${graphqlResult.issues.length} issues from ${graphqlResult.repoCount} repositories`);
46
+ return graphqlResult.issues;
47
+ }
48
+ }
49
+ // Strategy 2: Fallback to gh api --paginate approach (comprehensive but slower)
50
+ await log(' 📋 Using gh api --paginate approach for comprehensive coverage...', { verbose: true });
51
+
52
+ // Get list of ALL repositories using gh api with --paginate (includes isArchived for filtering)
53
+ const scopePath = scope === 'organization' ? 'orgs' : 'users';
54
+ const repoListCmd = `gh api ${scopePath}/${owner}/repos --paginate --jq '.[] | {name: .name, owner: .owner.login, isArchived: .archived}'`;
55
+ await log(' 📋 Fetching repository list (using --paginate for unlimited pagination)...', { verbose: true });
56
+ await log(` 🔎 Command: ${repoListCmd}`, { verbose: true });
57
+ // Add delay for rate limiting
58
+ await sleeper(2000);
59
+ // #1756: route through execGhWithRetry for transient 5xx + rate-limit
60
+ const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
61
+ execOptions: { encoding: 'utf8', env: process.env },
62
+ label: `gh api ${scope} repos (paginated)`,
63
+ });
64
+ // Parse the output line by line, as gh api with --jq outputs one JSON object per line
65
+ const repoLines = repoOutput
66
+ .trim()
67
+ .split('\n')
68
+ .filter(line => line.trim());
69
+ const allRepositories = repoLines.map(line => JSON.parse(line));
70
+ await log(` 📊 Found ${allRepositories.length} repositories`);
71
+ // Filter repositories to only include those owned by the target user/org
72
+ const ownedRepositories = allRepositories.filter(repo => {
73
+ const repoOwner = repo.owner?.login || repo.owner;
74
+ return repoOwner === owner;
75
+ });
76
+ const unownedCount = allRepositories.length - ownedRepositories.length;
77
+ if (unownedCount > 0) {
78
+ await log(` ⏭️ Skipping ${unownedCount} repository(ies) not owned by ${owner}`);
79
+ }
80
+ // Filter out archived repositories from owned repositories
81
+ const repositories = ownedRepositories.filter(repo => !repo.isArchived);
82
+ const archivedCount = ownedRepositories.length - repositories.length;
83
+ if (archivedCount > 0) {
84
+ await log(` ⏭️ Skipping ${archivedCount} archived repository(ies)`);
85
+ }
86
+ await log(` ✅ Processing ${repositories.length} non-archived repositories owned by ${owner}`);
87
+ const collectedIssues = [];
88
+ let processedRepos = 0;
89
+ // Process repositories in batches to avoid overwhelming the API
90
+ for (const repo of repositories) {
91
+ try {
92
+ const repoName = repo.name;
93
+ const ownerName = repo.owner?.login || owner;
94
+ await log(` 🔍 Fetching issues from ${ownerName}/${repoName}...`, { verbose: true });
95
+ // Build the appropriate issue list command
96
+ const labelFilter = fetchAllIssues ? '' : ` --label "${monitorTag}"`;
97
+ const issueCmd = `gh issue list --repo ${ownerName}/${repoName} --state open${labelFilter} --json url,title,number,createdAt`;
98
+ // Add delay between repository requests
99
+ await sleeper(1000);
100
+ const repoIssues = await fetchAllIssuesWithPagination(issueCmd);
101
+ // Add repository information to each issue
102
+ const issuesWithRepo = repoIssues.map(issue => ({
103
+ ...issue,
104
+ repository: { name: repoName, owner: { login: ownerName } },
105
+ }));
106
+ collectedIssues.push(...issuesWithRepo);
107
+ processedRepos++;
108
+ if (issuesWithRepo.length > 0) {
109
+ await log(` ✅ Found ${issuesWithRepo.length} issues in ${ownerName}/${repoName}`, { verbose: true });
110
+ }
111
+ } catch (repoError) {
112
+ reportError(repoError, { context: 'fetchIssuesFromRepositories', repo: repo.name, operation: 'fetch_repo_issues' });
113
+ await log(` ⚠️ Failed to fetch issues from ${repo.name}: ${cleanErrorMessage(repoError)}`, { verbose: true });
114
+ // Continue with other repositories
115
+ }
116
+ }
117
+ await log(` ✅ Repository fallback complete: ${collectedIssues.length} issues from ${processedRepos}/${repositories.length} repositories`);
118
+ return collectedIssues;
119
+ } catch (error) {
120
+ reportError(error, { context: 'fetchIssuesFromRepositories', owner, scope, operation: 'repository_fallback' });
121
+ await log(` ❌ Repository fallback failed: ${cleanErrorMessage(error)}`, { level: 'error' });
122
+ return [];
123
+ }
124
+ };
125
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pre-flight checks the hive runs before it starts monitoring.
3
+ *
4
+ * Extracted from src/hive.mjs (issue #2175) so the entry point stays under the
5
+ * 1350-line early-warning threshold that protects concurrent merges (#1593).
6
+ * Behaviour is unchanged; the collaborators that used to be closure bindings in
7
+ * hive.mjs are parameters, which also makes the sequence unit-testable
8
+ * (see tests/hive-startup-checks-2175.test.mjs).
9
+ */
10
+
11
+ /**
12
+ * Run the startup checks, exiting through `safeExit` when one fails.
13
+ *
14
+ * @param {object} deps
15
+ * @param {object} deps.argv parsed CLI arguments
16
+ * @param {Function} deps.log
17
+ * @param {Function} deps.safeExit
18
+ * @param {Function} deps.ensureDiskSpaceForWorker
19
+ * @param {Function} deps.checkSystem
20
+ * @param {Function} deps.validateToolConnection
21
+ * @param {Function} deps.validateClaudeConnection
22
+ * @param {number} deps.EXIT_CODE_INSUFFICIENT_DISK_SPACE
23
+ * @returns {Promise<{skipped: boolean}>}
24
+ */
25
+ export async function runStartupChecks({ argv, log, safeExit, ensureDiskSpaceForWorker, checkSystem, validateToolConnection, validateClaudeConnection, EXIT_CODE_INSUFFICIENT_DISK_SPACE }) {
26
+ if (argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false) {
27
+ await log('⏩ Skipping system resource check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
28
+ await log('⏩ Skipping AI tool connection check (dry-run mode or skip-tool-connection-check enabled)', { verbose: true });
29
+ return { skipped: true };
30
+ }
31
+
32
+ // Issue #2160: reclaim idle solver workspaces left behind by earlier runs before refusing to
33
+ // start, and report an exhausted disk as the environment condition it is (exit 75) instead of
34
+ // a generic error. `exitOnFailure` is deliberately not used: it calls process.exit(1)
35
+ // directly, which skips the log-flushing safeExit path and printed no actionable reason.
36
+ const startupRequiredDiskSpaceMB = argv.minDiskSpace || 10240;
37
+ const startupDiskGuard = await ensureDiskSpaceForWorker({ requiredMB: startupRequiredDiskSpaceMB, log });
38
+ if (!startupDiskGuard.ok) {
39
+ await log(`❌ Insufficient disk space to start: ${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required`, { level: 'error' });
40
+ await log(' Free space on this host, or run with --auto-cleanup so workspaces are removed after each task.', { level: 'error' });
41
+ await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space (${startupDiskGuard.freeMB}MB available, ${startupRequiredDiskSpaceMB}MB required)`);
42
+ }
43
+
44
+ const systemCheck = await checkSystem({ minDiskSpaceMB: startupRequiredDiskSpaceMB, minMemoryMB: 256 }, { log });
45
+ if (!systemCheck.success) {
46
+ await safeExit(1, 'System resource check failed');
47
+ }
48
+
49
+ // Validate the selected AI tool connection before starting monitoring with the same model that will be used
50
+ const isToolConnected = await validateToolConnection({ tool: argv.tool, model: argv.model, verbose: argv.verbose, validateClaudeConnection });
51
+ if (!isToolConnected) {
52
+ await log(`❌ Cannot start monitoring without ${argv.tool || 'claude'} connection`, { level: 'error' });
53
+ await safeExit(1, 'Error occurred');
54
+ }
55
+
56
+ return { skipped: false };
57
+ }
@@ -47,6 +47,10 @@ async function getCommandStreamDollar() {
47
47
  }
48
48
  // Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
49
49
  export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
50
+ // Issue #2175: the `$` output parsers live in their own module to keep this file
51
+ // under the 1350-line warning threshold. Re-exported so importers are unaffected.
52
+ import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
53
+ export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
50
54
  // Valid isolation backends
51
55
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
52
56
  const DOCKER_CONTAINER_HOME = '/home/box';
@@ -59,17 +63,6 @@ const DOCKER_ISOLATION_SHELL = 'sh';
59
63
  const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
60
64
  // Docker-only start gate used to capture the container writable-layer baseline before the task command begins cloning or generating files. The parent releases the gate immediately after `docker inspect --size`; the fallback keeps the task from hanging forever if the parent exits at the wrong time.
61
65
  const DOCKER_START_GATE_WAIT_TENTHS = 300;
62
- // Sentinel start-command's detached docker logger records when it cannot capture the container's real exit code. A terminal `$ --status` carrying this value is ambiguous — the container may still be running — so we cross-check it against a live `docker inspect` before concluding the session finished. See #1939. The upstream emission of this premature sentinel was fixed in start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images now pin; this cross-check is retained as defense-in-depth so an older `$` on an operator's PATH cannot resurrect the bug.
63
- const DOCKER_UNKNOWN_EXIT_CODE = -1;
64
- function normalizeProcessIds(value) {
65
- if (!value || typeof value !== 'object') return {};
66
- const out = {};
67
- for (const [key, raw] of Object.entries(value)) {
68
- const number = Number(raw);
69
- if (Number.isInteger(number) && number > 0) out[key] = number;
70
- }
71
- return out;
72
- }
73
66
  function normalizeTool(tool) {
74
67
  return String(tool || 'claude')
75
68
  .trim()
@@ -269,233 +262,6 @@ async function runStartCommand(binPath, startCommandArgs) {
269
262
  export function generateSessionId() {
270
263
  return crypto.randomUUID();
271
264
  }
272
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
273
- /**
274
- * Extract start-command's own execution UUID from a launch banner.
275
- *
276
- * Issue #2154: an isolated task has two UUIDs. Hive Mind generates the session
277
- * name and passes it as `--session` (it also becomes the container name);
278
- * start-command mints a separate execution UUID and prints it as the `session`
279
- * field of its launch banner:
280
- *
281
- * ```
282
- * │ session edc7b051-e12f-4f7b-b677-c885f3208407
283
- * │ container 0a3627ef-f1f1-4801-a073-3678b9453db7
284
- * ```
285
- *
286
- * `$ --list` shows the execution UUID, while Telegram and the logs showed the
287
- * session UUID, so the two views could not be joined — which is why three
288
- * refused tasks and two healthy ones looked equally unaccounted for. Returning
289
- * it lets the caller record both.
290
- *
291
- * Only a well-formed UUID is returned; a banner we do not recognise yields
292
- * null rather than a guess, because a wrong correlation is worse than none.
293
- *
294
- * @param {string} output - Raw stdout from the detached `$` launch
295
- * @returns {string|null}
296
- */
297
- export function parseStartCommandExecutionUuid(output) {
298
- const raw = (output || '').trim();
299
- if (!raw) return null;
300
- try {
301
- const parsed = JSON.parse(raw);
302
- const data = Array.isArray(parsed) ? parsed[0] : parsed;
303
- const uuid = data?.uuid || data?.session || null;
304
- if (typeof uuid === 'string' && UUID_PATTERN.test(uuid.trim())) return uuid.trim();
305
- } catch {
306
- // Human-readable banner — fall through.
307
- }
308
- // The banner is box-drawn (`│ session <uuid>`); tolerate the prefix, an
309
- // ASCII `|`, or no prefix at all.
310
- const match = raw.match(/^[\s│|]*session\s+([^\s]+)\s*$/im);
311
- const candidate = match?.[1]?.trim();
312
- return candidate && UUID_PATTERN.test(candidate) ? candidate : null;
313
- }
314
- /**
315
- * Parse output from `$ --status <session>`.
316
- *
317
- * start-command versions used in the wild may return JSON when
318
- * `--output-format json` is supported, or human-readable key/value text.
319
- * Keep the parser tolerant so completion monitoring survives either format.
320
- *
321
- * @param {string} output - Raw stdout from `$ --status`
322
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
323
- */
324
- export function parseSessionStatusOutput(output) {
325
- const raw = (output || '').trim();
326
- if (!raw) {
327
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
328
- }
329
- const normalizeBooleanField = value => {
330
- if (typeof value === 'boolean') return value;
331
- if (value === null || value === undefined) return null;
332
- const normalized = String(value).trim().toLowerCase();
333
- if (['true', '1', 'yes'].includes(normalized)) return true;
334
- if (['false', '0', 'no'].includes(normalized)) return false;
335
- return null;
336
- };
337
- try {
338
- const parsed = JSON.parse(raw);
339
- const data = Array.isArray(parsed) ? parsed[0] : parsed;
340
- // start-command (link-foundation/start) reports the isolation backend at `options.isolated` in both JSON and links-notation output. Older hypothetical layouts used `options.isolation` or a top-level `isolation` field — keep accepting all three so we are tolerant of future renames. See https://github.com/link-assistant/hive-mind/issues/1700.
341
- const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
342
- const topPid = Number(data?.pid);
343
- const processIds = normalizeProcessIds(data?.processIds);
344
- if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
345
- return {
346
- exists: true,
347
- uuid: data?.uuid || null,
348
- status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
349
- exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
350
- startTime: data?.startTime || null,
351
- endTime: data?.endTime || null,
352
- currentTime: data?.currentTime || null,
353
- logPath: data?.logPath || null,
354
- command: data?.command || null,
355
- isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
356
- workingDirectory: data?.workingDirectory || null,
357
- sessionName: data?.sessionName || data?.options?.sessionName || null,
358
- processIds,
359
- oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
360
- raw,
361
- };
362
- } catch {
363
- // Fall through to text parsing.
364
- }
365
- const firstLine =
366
- raw
367
- .split('\n')
368
- .find(line => line.trim() && !line.includes(' '))
369
- ?.trim() || null;
370
- const readField = name => {
371
- const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
372
- return match ? match[1].trim() : null;
373
- };
374
- const readBooleanField = name => normalizeBooleanField(readField(name));
375
- const status = readField('status')?.toLowerCase() || null;
376
- const exitCodeText = readField('exitCode');
377
- // `start-command` links-notation output nests the isolation backend under `options` as `isolated <backend>` (not `isolation`). The leading indent varies by depth, but `readField` is anchored with `^\s*` which already matches indented lines. Older code only looked for `isolation`, which
378
- // returned null for every real session and made /log + /terminal_watch
379
- // reject screen/tmux/docker sessions. See issue #1700.
380
- const isolationText = readField('isolated') || readField('isolation');
381
- const processIds = {};
382
- for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
383
- const value = readField(name);
384
- const number = Number(value);
385
- if (Number.isInteger(number) && number > 0) processIds[name] = number;
386
- }
387
- return {
388
- exists: Boolean(status || firstLine),
389
- uuid: readField('uuid') || firstLine,
390
- status,
391
- exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
392
- startTime: readField('startTime'),
393
- endTime: readField('endTime'),
394
- currentTime: readField('currentTime'),
395
- logPath: readField('logPath'),
396
- command: readField('command'),
397
- isolation: isolationText?.toLowerCase() || null,
398
- workingDirectory: readField('workingDirectory'),
399
- sessionName: readField('sessionName'),
400
- processIds,
401
- oomKilled: readBooleanField('oomKilled'),
402
- raw,
403
- };
404
- }
405
- /**
406
- * Decide whether a detached-docker exit code is "unknown" (not a real result).
407
- *
408
- * start-command's detached docker logger writes the exit-code footer only after
409
- * `docker logs -f` returns, capturing the real code via `docker inspect`. When
410
- * it cannot capture one it records the sentinel `-1`. A `$ --status` that
411
- * reports a terminal status ("executed") while still carrying that sentinel — or
412
- * no exit code at all — is therefore ambiguous: the container may actually still
413
- * be running. Callers treat such a status as provisional and cross-check the
414
- * live container before declaring the session finished. See issue #1939.
415
- *
416
- * @param {number|null|undefined} exitCode
417
- * @returns {boolean} True when the exit code carries no real result.
418
- */
419
- export function isUnknownDockerExitCode(exitCode) {
420
- return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
421
- }
422
- export function shouldFallbackToScreenStatus(statusResult) {
423
- return !statusResult?.exists || !statusResult?.status;
424
- }
425
- /**
426
- * Parse the footer start-command appends to every execution log when the wrapped
427
- * command exits. The footer is authoritative about the terminal exit code even
428
- * when `$ --status` is wrong: start-command writes it from the command's own
429
- * `close`/`exited` handler, so its presence proves the command terminated.
430
- *
431
- * Footer shape (see start-command spawn-helpers.js):
432
- *
433
- * ==================================================
434
- * Finished: 2026-06-14 19:10:49.822
435
- * Exit Code: 137
436
- *
437
- * Issue #1927: start-command's `enrichDetachedStatus` can flip a completed
438
- * `executed/137` record back to `executing` (nulling the exit code) when a
439
- * lingering shell keeps the screen session alive — so `$ --status` reports
440
- * `executing` forever and the bot never notices the kill. Reading this footer
441
- * lets hive-mind detect the real terminal exit regardless of that flip.
442
- *
443
- * @param {string} text - Log text (typically the tail of the log file)
444
- * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
445
- */
446
- export function parseSessionExitFooter(text) {
447
- if (!text) return { finished: false, exitCode: null, endTime: null };
448
- // Match the LAST footer block in the text (a re-run could append more than
449
- // one). Anchor on the `=` separator so command output that merely prints
450
- // "Exit Code: N" mid-stream is not mistaken for the footer.
451
- const re = /={10,}\s*\r?\nFinished:\s*([^\r\n]+)\r?\nExit Code:\s*(-?\d+)/g;
452
- let match;
453
- let last = null;
454
- while ((match = re.exec(text)) !== null) last = match;
455
- if (!last) return { finished: false, exitCode: null, endTime: null };
456
- return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
457
- }
458
- /**
459
- * Read the terminal exit code from the tail of a start-command execution log.
460
- *
461
- * Only the last `tailBytes` of the file are read (the footer lives at the end),
462
- * so this is cheap even for multi-megabyte logs. Never throws — a missing or
463
- * unreadable log yields `{ finished: false }`.
464
- *
465
- * @param {string} logPath
466
- * @param {Object} [options]
467
- * @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
468
- * @param {number} [options.tailBytes=16384] - How many trailing bytes to scan
469
- * @param {boolean} [options.verbose]
470
- * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
471
- */
472
- export function readSessionExitFromLog(logPath, options = {}) {
473
- const { fsImpl = fs, tailBytes = 16384, verbose = false } = options;
474
- if (!logPath) return { finished: false, exitCode: null, endTime: null };
475
- try {
476
- const { size } = fsImpl.statSync(logPath);
477
- if (!size) return { finished: false, exitCode: null, endTime: null };
478
- const start = Math.max(0, size - tailBytes);
479
- const length = size - start;
480
- const buffer = Buffer.alloc(length);
481
- const fd = fsImpl.openSync(logPath, 'r');
482
- try {
483
- fsImpl.readSync(fd, buffer, 0, length, start);
484
- } finally {
485
- fsImpl.closeSync(fd);
486
- }
487
- const result = parseSessionExitFooter(buffer.toString('utf8'));
488
- if (verbose && result.finished) {
489
- console.log(`[VERBOSE] isolation-runner: log footer for ${logPath} reports exit ${result.exitCode} (finished ${result.endTime})`);
490
- }
491
- return result;
492
- } catch (error) {
493
- if (verbose) {
494
- console.log(`[VERBOSE] isolation-runner: could not read exit footer from ${logPath}: ${error.message}`);
495
- }
496
- return { finished: false, exitCode: null, endTime: null };
497
- }
498
- }
499
265
  /**
500
266
  * Find the `$` CLI binary path
501
267
  * @returns {Promise<string|null>} Path to `$` binary or null
@@ -700,45 +466,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
700
466
  return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
701
467
  }
702
468
  }
703
- /**
704
- * Parse output from `$ --list --output-format json`.
705
- *
706
- * start-command may return a top-level array, or an object with an
707
- * `executions`/`sessions` array. Each entry is normalized to the same shape used
708
- * by {@link parseSessionStatusOutput} (uuid/status/exitCode/command/isolation/…).
709
- * Tolerant of unknown layouts — anything unparseable yields an empty list.
710
- *
711
- * @param {string} output - Raw stdout from `$ --list`
712
- * @returns {Array<{uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null}>}
713
- */
714
- export function parseSessionListOutput(output) {
715
- const raw = (output || '').trim();
716
- if (!raw) return [];
717
- let parsed;
718
- try {
719
- parsed = JSON.parse(raw);
720
- } catch {
721
- return [];
722
- }
723
- const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
724
- return records
725
- .map(data => {
726
- if (!data || typeof data !== 'object') return null;
727
- const isolationCandidate = (typeof data.isolation === 'string' && data.isolation) || (typeof data.options?.isolated === 'string' && data.options.isolated) || (typeof data.options?.isolation === 'string' && data.options.isolation) || null;
728
- return {
729
- uuid: data.uuid || data.session || data.sessionId || null,
730
- status: typeof data.status === 'string' ? data.status.toLowerCase() : null,
731
- exitCode: data.exitCode !== undefined && data.exitCode !== null ? Number(data.exitCode) : null,
732
- startTime: data.startTime || null,
733
- endTime: data.endTime || null,
734
- command: data.command || null,
735
- isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
736
- workingDirectory: data.workingDirectory || null,
737
- sessionName: data.sessionName || data.options?.sessionName || null,
738
- };
739
- })
740
- .filter(Boolean);
741
- }
742
469
  /**
743
470
  * List all executions known to start-command via `$ --list --output-format json`.
744
471
  *