@link-assistant/hive-mind 1.46.6 → 1.46.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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.46.8
4
+
5
+ ### Patch Changes
6
+
7
+ - bcf2b9b: Retry on network issues and minimize terminal/log output differences (#1536): add ghRetry/ghCmdRetry utilities with exponential backoff for transient network errors (TCP reset, TLS timeout, connection refused, unexpected EOF). Apply retry to critical gh CLI calls: accept-invite, repository setup, auto-fork permission check, visibility detection, write permission check. Log stderr to log file on command failure for terminal/log parity. Add 'unexpected eof' to transient error detection patterns.
8
+
9
+ ## 1.46.7
10
+
11
+ ### Patch Changes
12
+
13
+ - 249cf93: Fix --isolation option not working in /solve and /hive Telegram commands (#1534): extract --isolation from user args before validation, so it's used for execution isolation (via $ CLI from start-command) instead of being forwarded to solve/hive as an unknown argument. Per-command --isolation takes precedence over bot-level ISOLATION_BACKEND setting.
14
+
3
15
  ## 1.46.6
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.46.6",
3
+ "version": "1.46.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",
@@ -2,7 +2,7 @@
2
2
  // GitHub-related utility functions. Check if use is already defined (when imported from solve.mjs), if not, fetch it (when running standalone)
3
3
  if (typeof globalThis.use === 'undefined') globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
4
4
  const { $ } = await use('command-stream'); // Use command-stream for consistent $ behavior
5
- import { log, maskToken, cleanErrorMessage, isENOSPC } from './lib.mjs';
5
+ import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.mjs';
6
6
  import { reportError } from './sentry.lib.mjs';
7
7
  import { githubLimits, timeouts } from './config.lib.mjs';
8
8
  import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
@@ -172,8 +172,8 @@ export const checkRepositoryWritePermission = async (owner, repo, options = {})
172
172
  }
173
173
  try {
174
174
  await log('šŸ” Checking repository write permissions...');
175
- // Use GitHub API to check repository permissions
176
- const permResult = await $`gh api repos/${owner}/${repo} --jq .permissions`;
175
+ // Use GitHub API to check repository permissions (issue #1536: retry on network errors)
176
+ const permResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: `write perms ${owner}/${repo}` });
177
177
  if (permResult.code !== 0) {
178
178
  // API call failed - might be a private repo or network issue
179
179
  const errorOutput = (permResult.stderr ? permResult.stderr.toString() : '') + (permResult.stdout ? permResult.stdout.toString() : '');
@@ -1437,7 +1437,8 @@ export async function handlePRNotFoundError({ prNumber, owner, repo, argv, shoul
1437
1437
  */
1438
1438
  export async function detectRepositoryVisibility(owner, repo) {
1439
1439
  try {
1440
- const visibilityResult = await $`gh api repos/${owner}/${repo} --jq .visibility`;
1440
+ // Issue #1536: retry on transient network errors
1441
+ const visibilityResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .visibility`, { label: `visibility ${owner}/${repo}` });
1441
1442
  if (visibilityResult.code === 0) {
1442
1443
  const visibility = visibilityResult.stdout.toString().trim();
1443
1444
  const isPublic = visibility === 'public';
package/src/lib.mjs CHANGED
@@ -291,11 +291,92 @@ export const isTransientNetworkError = error => {
291
291
  const output = (error?.stderr?.toString() || error?.stdout?.toString() || '').toLowerCase();
292
292
  const combined = msg + ' ' + output;
293
293
 
294
- const transientPatterns = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up'];
294
+ // Issue #1536: added 'unexpected eof' — seen in gh CLI when connection drops mid-response
295
+ const transientPatterns = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof'];
295
296
 
296
297
  return transientPatterns.some(pattern => combined.includes(pattern));
297
298
  };
298
299
 
300
+ /**
301
+ * Retry a GitHub CLI / API operation with exponential backoff on transient network errors.
302
+ * Unlike the generic `retry()`, this function:
303
+ * - Only retries on transient network errors (TCP reset, TLS timeout, etc.)
304
+ * - Immediately rethrows non-transient errors (404, 403, auth failures)
305
+ * - Logs stderr to the log file when a command fails (fixing terminal/log parity)
306
+ *
307
+ * Issue #1536: Most gh commands had no retry logic, causing solve to abort on
308
+ * intermittent network issues.
309
+ *
310
+ * @param {Function} fn - Async function to execute (should call gh CLI or GitHub API)
311
+ * @param {Object} [options] - Options
312
+ * @param {number} [options.maxAttempts=3] - Maximum number of attempts
313
+ * @param {number} [options.delay=1000] - Initial delay between retries in ms
314
+ * @param {number} [options.backoff=2] - Backoff multiplier
315
+ * @param {string} [options.label='gh command'] - Label for log messages
316
+ * @returns {Promise<*>} Result of successful function execution
317
+ * @throws {Error} Last error if all attempts fail or error is non-transient
318
+ */
319
+ export const ghRetry = async (fn, options = {}) => {
320
+ const { maxAttempts = 3, delay = 1000, backoff = 2, label = 'gh command' } = options;
321
+
322
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
323
+ try {
324
+ return await fn();
325
+ } catch (error) {
326
+ if (isTransientNetworkError(error) && attempt < maxAttempts) {
327
+ const waitTime = delay * Math.pow(backoff, attempt - 1);
328
+ await log(`āš ļø ${label}: Network error (attempt ${attempt}/${maxAttempts}), retrying in ${waitTime / 1000}s...`, { level: 'warn' });
329
+ await sleep(waitTime);
330
+ continue;
331
+ }
332
+ throw error;
333
+ }
334
+ }
335
+ };
336
+
337
+ /**
338
+ * Execute a command-stream `$` call with retry on transient network errors.
339
+ * This wraps the pattern: call $`gh ...`, check exit code, handle errors.
340
+ * On failure, stderr is logged to the log file (fixing terminal/log parity from issue #1536).
341
+ *
342
+ * @param {Function} cmdFn - Function that returns a command-stream result (e.g., () => $`gh api ...`)
343
+ * @param {Object} [options] - Options
344
+ * @param {number} [options.maxAttempts=3] - Maximum number of attempts
345
+ * @param {number} [options.delay=1000] - Initial delay between retries in ms
346
+ * @param {number} [options.backoff=2] - Backoff multiplier
347
+ * @param {string} [options.label='gh command'] - Label for log messages
348
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
349
+ */
350
+ export const ghCmdRetry = async (cmdFn, options = {}) => {
351
+ const { maxAttempts = 3, delay = 1000, backoff = 2, label = 'gh command' } = options;
352
+
353
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
354
+ const result = await cmdFn();
355
+
356
+ // Log stderr to log file for parity (issue #1536)
357
+ const stderr = result.stderr?.toString().trim();
358
+ if (stderr && result.code !== 0) {
359
+ await log(` [stderr] ${stderr}`, { level: 'warn' });
360
+ }
361
+
362
+ if (result.code === 0) {
363
+ return result;
364
+ }
365
+
366
+ // Check if this is a transient network error worth retrying
367
+ const combinedOutput = (result.stdout?.toString() || '') + ' ' + (result.stderr?.toString() || '');
368
+ if (isTransientNetworkError({ message: combinedOutput }) && attempt < maxAttempts) {
369
+ const waitTime = delay * Math.pow(backoff, attempt - 1);
370
+ await log(`āš ļø ${label}: Network error (attempt ${attempt}/${maxAttempts}), retrying in ${waitTime / 1000}s...`, { level: 'warn' });
371
+ await sleep(waitTime);
372
+ continue;
373
+ }
374
+
375
+ // Non-transient error or last attempt — return the result as-is
376
+ return result;
377
+ }
378
+ };
379
+
299
380
  /**
300
381
  * Format bytes to human readable string
301
382
  * @param {number} bytes - Number of bytes
@@ -5,9 +5,13 @@
5
5
  * this module only accepts the invitation for the specific repository or organization
6
6
  * that is being solved. This is safer and more targeted.
7
7
  *
8
+ * Issue #1536: Added retry with exponential backoff for transient network errors
9
+ * (TLS handshake timeout, unexpected EOF, connection reset, etc.)
10
+ *
8
11
  * @see https://docs.github.com/en/rest/collaborators/invitations
9
12
  * @see https://docs.github.com/en/rest/orgs/members
10
13
  * @see https://github.com/link-assistant/hive-mind/issues/1373
14
+ * @see https://github.com/link-assistant/hive-mind/issues/1536
11
15
  */
12
16
 
13
17
  import { promisify } from 'util';
@@ -15,6 +19,10 @@ import { exec as execCallback } from 'child_process';
15
19
 
16
20
  const exec = promisify(execCallback);
17
21
 
22
+ // Import retry utility (issue #1536)
23
+ const lib = await import('./lib.mjs');
24
+ const { ghRetry } = lib;
25
+
18
26
  /**
19
27
  * Accepts pending GitHub repository or organization invitation for a specific target.
20
28
  *
@@ -35,7 +43,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
35
43
 
36
44
  // Check for pending repository invitation
37
45
  try {
38
- const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"');
46
+ const { stdout: repoInvJson } = await ghRetry(() => exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"'), { label: 'fetch repo invitations' });
39
47
  const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
40
48
  verbose && (await log(` Found ${repoInvitations.length} total pending repo invitation(s)`, { verbose: true }));
41
49
 
@@ -43,7 +51,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
43
51
 
44
52
  if (matchingInv) {
45
53
  try {
46
- await exec(`gh api -X PATCH /user/repository_invitations/${matchingInv.id}`);
54
+ await ghRetry(() => exec(`gh api -X PATCH /user/repository_invitations/${matchingInv.id}`), { label: `accept repo invitation for ${fullName}` });
47
55
  await log(`āœ… --auto-accept-invite: Accepted repository invitation for ${fullName}`);
48
56
  result.acceptedRepo = true;
49
57
  } catch (e) {
@@ -58,7 +66,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
58
66
 
59
67
  // Check for pending organization membership
60
68
  try {
61
- const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
69
+ const { stdout: orgMemJson } = await ghRetry(() => exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"'), { label: 'fetch org memberships' });
62
70
  const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
63
71
  const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
64
72
  verbose && (await log(` Found ${pendingOrgs.length} total pending org invitation(s)`, { verbose: true }));
@@ -68,7 +76,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
68
76
  if (matchingOrg) {
69
77
  const orgName = matchingOrg.organization.login;
70
78
  try {
71
- await exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`);
79
+ await ghRetry(() => exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`), { label: `accept org invitation for ${orgName}` });
72
80
  await log(`āœ… --auto-accept-invite: Accepted organization invitation for ${orgName}`);
73
81
  result.acceptedOrg = true;
74
82
  } catch (e) {
package/src/solve.mjs CHANGED
@@ -239,9 +239,9 @@ const claudePath = argv.executeToolWithBun ? 'bunx claude' : process.env.CLAUDE_
239
239
  if (argv.autoFork && !argv.fork) {
240
240
  const { detectRepositoryVisibility } = githubLib;
241
241
 
242
- // Check if we have write access first
242
+ // Check if we have write access first (issue #1536: retry on transient network errors)
243
243
  await log('šŸ” Checking repository access for auto-fork...');
244
- const permResult = await $`gh api repos/${owner}/${repo} --jq .permissions`;
244
+ const permResult = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
245
245
 
246
246
  if (permResult.code === 0) {
247
247
  const permissions = JSON.parse(permResult.stdout.toString().trim());
@@ -36,7 +36,7 @@ const { checkRepositoryWritePermission } = githubLib;
36
36
  // Get root repository (fork source or self), or null if inaccessible
37
37
  export const getRootRepository = async (owner, repo) => {
38
38
  try {
39
- const result = await $`gh api repos/${owner}/${repo} --jq '{fork: .fork, source: .source.full_name}' 2>&1`;
39
+ const result = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq '{fork: .fork, source: .source.full_name}' 2>&1`, { label: `get root repo ${owner}/${repo}` });
40
40
  if (result.code !== 0) return null;
41
41
 
42
42
  const repoInfo = JSON.parse(result.stdout.toString().trim());
@@ -50,11 +50,10 @@ export const getRootRepository = async (owner, repo) => {
50
50
  // Check if current user has a fork of the given root repository
51
51
  export const checkExistingForkOfRoot = async rootRepo => {
52
52
  try {
53
- const userResult = await $`gh api user --jq .login`;
53
+ const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get user (fork check)' });
54
54
  if (userResult.code !== 0) return null;
55
55
  const currentUser = userResult.stdout.toString().trim();
56
-
57
- const forksResult = await $`gh api repos/${rootRepo}/forks --paginate --jq '.[] | select(.owner.login == "${currentUser}") | .full_name'`;
56
+ const forksResult = await lib.ghCmdRetry(() => $`gh api repos/${rootRepo}/forks --paginate --jq '.[] | select(.owner.login == "${currentUser}") | .full_name'`, { label: `check forks of ${rootRepo}` });
58
57
  if (forksResult.code !== 0) return null;
59
58
 
60
59
  const forks = forksResult.stdout
@@ -363,8 +362,8 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
363
362
  await log(`\n${formatAligned('šŸ“', 'Fork mode:', 'ENABLED')}`);
364
363
  await log(`${formatAligned('', 'Checking fork status...', '')}\n`);
365
364
 
366
- // Get current user
367
- const userResult = await $`gh api user --jq .login`;
365
+ // Get current user (issue #1536: retry on transient network errors)
366
+ const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get current user' });
368
367
  if (userResult.code !== 0) {
369
368
  await log(`${formatAligned('āŒ', 'Error:', 'Failed to get current user')}`);
370
369
  await safeExit(1, 'Repository setup failed');
@@ -40,6 +40,7 @@ const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config
40
40
  const { parseGitHubUrl } = await import('./github.lib.mjs');
41
41
  const { validateModelName, buildModelOptionDescription } = await import('./models/index.mjs');
42
42
  const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
43
+ const { extractIsolationFromArgs, isValidPerCommandIsolation, resolveIsolation, createIsolationAwareQueueCallback } = await import('./telegram-isolation.lib.mjs');
43
44
  const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mjs');
44
45
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
45
46
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
@@ -586,8 +587,7 @@ async function safeReply(ctx, text, options = {}) {
586
587
  }
587
588
  }
588
589
 
589
- // Execute a command via isolation mode ($ from start-command) or start-screen, then update message
590
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock) {
590
+ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null) {
591
591
  const { chat, message_id: msgId } = startingMessage;
592
592
  const safeEdit = async text => {
593
593
  try {
@@ -596,16 +596,16 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
596
596
  console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
597
597
  }
598
598
  };
599
+ const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
599
600
  let result,
600
601
  session,
601
602
  extraInfo = '';
602
- if (ISOLATION_BACKEND && isolationRunner) {
603
- const sid = isolationRunner.generateSessionId();
604
- VERBOSE && console.log(`[VERBOSE] Using isolation (${ISOLATION_BACKEND}), session: ${sid}`);
605
- result = await isolationRunner.executeWithIsolation(commandName, args, { backend: ISOLATION_BACKEND, sessionId: sid, verbose: VERBOSE });
606
- session = sid;
607
- extraInfo = `\nšŸ”’ Isolation: \`${ISOLATION_BACKEND}\``;
608
- if (result.success) trackSession(sid, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: ISOLATION_BACKEND, sessionId: sid }, VERBOSE);
603
+ if (iso) {
604
+ session = iso.runner.generateSessionId();
605
+ VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
606
+ result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
607
+ extraInfo = `\nšŸ”’ Isolation: \`${iso.backend}\``;
608
+ 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);
609
609
  } else {
610
610
  result = await executeStartScreen(commandName, args);
611
611
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
@@ -934,9 +934,12 @@ async function handleSolveCommand(ctx) {
934
934
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
935
935
  return;
936
936
  }
937
-
938
- // Merge user args with overrides
939
- const args = mergeArgsWithOverrides(userArgs, solveOverrides);
937
+ const { backend: solvePerCommandIsolation, filteredArgs: userArgsWithoutIsolation } = extractIsolationFromArgs(userArgs); // issue #1534
938
+ if (solvePerCommandIsolation && !isValidPerCommandIsolation(solvePerCommandIsolation)) {
939
+ await safeReply(ctx, `āŒ Invalid --isolation value '${escapeMarkdown(solvePerCommandIsolation)}'. Must be: screen, tmux, or docker`, { reply_to_message_id: ctx.message.message_id });
940
+ return;
941
+ }
942
+ const args = mergeArgsWithOverrides(userArgsWithoutIsolation, solveOverrides);
940
943
 
941
944
  // Determine tool from args (default: claude)
942
945
  let solveTool = 'claude';
@@ -1024,23 +1027,16 @@ async function handleSolveCommand(ctx) {
1024
1027
 
1025
1028
  if (check.canStart && queueStats.queued === 0) {
1026
1029
  const startingMessage = await safeReply(ctx, `šŸš€ Starting solve command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1027
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
1030
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, solvePerCommandIsolation);
1028
1031
  } else {
1029
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool });
1032
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: solvePerCommandIsolation });
1030
1033
  let queueMessage = `šŸ“‹ Solve command queued (position #${queueStats.queued + 1})\n\n${infoBlock}`;
1031
1034
  if (check.reason) queueMessage += `\n\nā³ Waiting: ${escapeMarkdown(check.reason)}`;
1032
1035
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
1033
1036
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
1034
1037
  if (!solveQueue.executeCallback) {
1035
- solveQueue.executeCallback =
1036
- ISOLATION_BACKEND && isolationRunner
1037
- ? async item => {
1038
- const sid = isolationRunner.generateSessionId();
1039
- const r = await isolationRunner.executeWithIsolation('solve', item.args, { backend: ISOLATION_BACKEND, sessionId: sid, verbose: VERBOSE });
1040
- if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', isolationBackend: ISOLATION_BACKEND, sessionId: sid }, VERBOSE);
1041
- return { ...r, output: r.output || `session: ${sid}` };
1042
- }
1043
- : createQueueExecuteCallback(executeStartScreen, (session, info) => trackSession(session, info, VERBOSE));
1038
+ const _t = (s, i) => trackSession(s, i, VERBOSE);
1039
+ solveQueue.executeCallback = createIsolationAwareQueueCallback(ISOLATION_BACKEND, isolationRunner, _t, createQueueExecuteCallback(executeStartScreen, _t), VERBOSE);
1044
1040
  }
1045
1041
  }
1046
1042
  }
@@ -1135,8 +1131,12 @@ async function handleHiveCommand(ctx) {
1135
1131
  if (VERBOSE) console.log(`[VERBOSE] /hive: Normalized ${p.type} URL to repo URL: ${normalizedArgs[0]}`);
1136
1132
  } else if (validation.normalizedUrl && validation.normalizedUrl !== userArgs[0]) normalizedArgs[0] = validation.normalizedUrl;
1137
1133
 
1138
- // Merge user args with overrides
1139
- const args = mergeArgsWithOverrides(normalizedArgs, hiveOverrides);
1134
+ const { backend: hivePerCommandIsolation, filteredArgs: normalizedArgsWithoutIsolation } = extractIsolationFromArgs(normalizedArgs); // issue #1534
1135
+ if (hivePerCommandIsolation && !isValidPerCommandIsolation(hivePerCommandIsolation)) {
1136
+ await safeReply(ctx, `āŒ Invalid --isolation value '${escapeMarkdown(hivePerCommandIsolation)}'. Must be: screen, tmux, or docker`, { reply_to_message_id: ctx.message.message_id });
1137
+ return;
1138
+ }
1139
+ const args = mergeArgsWithOverrides(normalizedArgsWithoutIsolation, hiveOverrides);
1140
1140
 
1141
1141
  // Determine tool from args (default: claude)
1142
1142
  let hiveTool = 'claude';
@@ -1195,7 +1195,7 @@ async function handleHiveCommand(ctx) {
1195
1195
  }
1196
1196
 
1197
1197
  const startingMessage = await safeReply(ctx, `šŸš€ Starting hive command...\n\n${infoBlock}`, { reply_to_message_id: ctx.message.message_id });
1198
- await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock);
1198
+ await executeAndUpdateMessage(ctx, startingMessage, 'hive', args, infoBlock, hivePerCommandIsolation);
1199
1199
  }
1200
1200
 
1201
1201
  bot.command(/^hive$/i, handleHiveCommand);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Per-command isolation support for Telegram bot commands.
3
+ *
4
+ * Extracts --isolation <backend> from user args in /solve and /hive commands,
5
+ * so it can be used for execution isolation (via $ CLI from start-command)
6
+ * instead of being forwarded to solve/hive as an unknown argument.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1534
9
+ * @see https://github.com/link-assistant/hive-mind/pull/390
10
+ */
11
+
12
+ const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
13
+
14
+ /**
15
+ * Extract --isolation <backend> from args array.
16
+ * Returns { backend: string|null, filteredArgs: string[] }.
17
+ * The --isolation flag is a per-command execution option (not a solve/hive option),
18
+ * so it must be stripped before passing args to solve/hive validation and execution.
19
+ */
20
+ export function extractIsolationFromArgs(args) {
21
+ const filteredArgs = [];
22
+ let backend = null;
23
+ for (let i = 0; i < args.length; i++) {
24
+ if (args[i] === '--isolation' && i + 1 < args.length) {
25
+ backend = args[i + 1].trim().toLowerCase();
26
+ i++; // Skip the value
27
+ } else if (args[i].startsWith('--isolation=')) {
28
+ backend = args[i].substring('--isolation='.length).trim().toLowerCase();
29
+ } else {
30
+ filteredArgs.push(args[i]);
31
+ }
32
+ }
33
+ return { backend, filteredArgs };
34
+ }
35
+
36
+ /**
37
+ * Validate an isolation backend value.
38
+ * @param {string} backend
39
+ * @returns {boolean}
40
+ */
41
+ export function isValidPerCommandIsolation(backend) {
42
+ return VALID_ISOLATION_BACKENDS.includes(backend);
43
+ }
44
+
45
+ /**
46
+ * Get the effective isolation backend and runner for a command execution.
47
+ * Per-command isolation takes precedence over bot-level ISOLATION_BACKEND.
48
+ *
49
+ * @param {string|null} perCommandIsolation - Per-command --isolation value from user args
50
+ * @param {string} botIsolationBackend - Bot-level ISOLATION_BACKEND
51
+ * @param {object|null} botIsolationRunner - Bot-level isolation runner module
52
+ * @param {boolean} verbose - Enable verbose logging
53
+ * @returns {Promise<{backend: string, runner: object}|null>}
54
+ */
55
+ export async function resolveIsolation(perCommandIsolation, botIsolationBackend, botIsolationRunner, verbose = false) {
56
+ const effectiveBackend = perCommandIsolation || botIsolationBackend;
57
+ if (!effectiveBackend) return null;
58
+
59
+ let runner = botIsolationRunner;
60
+ if (!runner) {
61
+ try {
62
+ runner = await import('./isolation-runner.lib.mjs');
63
+ if (verbose) console.log('[VERBOSE] Dynamically imported isolation-runner for per-command isolation');
64
+ } catch (e) {
65
+ console.error(`[telegram-bot] Failed to import isolation-runner: ${e.message}`);
66
+ return null;
67
+ }
68
+ }
69
+
70
+ return { backend: effectiveBackend, runner };
71
+ }
72
+
73
+ /**
74
+ * Create a queue execute callback that supports per-command isolation.
75
+ * Falls back to the provided fallback callback when no isolation is active.
76
+ */
77
+ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolationRunner, trackSession, fallbackCallback, verbose) {
78
+ return async item => {
79
+ const iso = await resolveIsolation(item.perCommandIsolation, botIsolationBackend, botIsolationRunner, verbose);
80
+ if (iso) {
81
+ const sid = iso.runner.generateSessionId();
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}` };
85
+ }
86
+ return fallbackCallback(item);
87
+ };
88
+ }