@link-assistant/hive-mind 1.46.5 → 1.46.7

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.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 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.
8
+
9
+ ## 1.46.6
10
+
11
+ ### Patch Changes
12
+
13
+ - 6ab718a: Fix --interactive-mode completely broken (#1532): replace promisify(execFile) with spawn-based execFileAsync that correctly pipes stdin to child processes. The Node.js promisify(execFile) silently ignores the `input` option, causing `gh api --input -` to hang forever waiting for stdin data that never arrives, which blocks the entire stream processing loop.
14
+
3
15
  ## 1.46.5
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.5",
3
+ "version": "1.46.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -52,12 +52,67 @@ const CONFIG = {
52
52
  // See: https://github.com/link-assistant/hive-mind/issues/1324
53
53
  import { sanitizeUnicode } from './unicode-sanitization.lib.mjs';
54
54
 
55
- // Use child_process for stdin-based API calls to avoid shell quoting issues
56
- // with large/complex comment bodies containing backticks, quotes, etc.
55
+ // Use child_process.spawn for stdin-based API calls to avoid shell quoting
56
+ // issues with large/complex comment bodies containing backticks, quotes, etc.
57
+ // IMPORTANT: We use spawn (not execFile) because promisify(execFile) silently
58
+ // ignores the `input` option — only the sync variants (execFileSync, execSync,
59
+ // spawnSync) support `input`. Using execFile with `input` causes `gh api --input -`
60
+ // to hang forever waiting for stdin, which blocks the stream processing loop and
61
+ // prevents interactive mode from working at all.
57
62
  // See: https://github.com/link-assistant/hive-mind/issues/1458
58
- import { execFile } from 'node:child_process';
59
- import { promisify } from 'node:util';
60
- const execFileAsync = promisify(execFile);
63
+ // See: https://github.com/link-assistant/hive-mind/issues/1532
64
+ import { spawn } from 'node:child_process';
65
+
66
+ /**
67
+ * Spawn a child process with stdin piping support.
68
+ * Unlike promisify(execFile), this correctly writes `input` to the child's
69
+ * stdin before closing it, so commands like `gh api --input -` work.
70
+ *
71
+ * @param {string} command - The command to run
72
+ * @param {string[]} args - Command arguments
73
+ * @param {Object} [options] - Options
74
+ * @param {string} [options.input] - Data to write to stdin
75
+ * @param {number} [options.maxBuffer=1048576] - Max stdout/stderr buffer size
76
+ * @returns {Promise<{stdout: string, stderr: string}>}
77
+ */
78
+ const execFileAsync = (command, args, options = {}) => {
79
+ return new Promise((resolve, reject) => {
80
+ const { input, maxBuffer = 1024 * 1024, ...spawnOpts } = options;
81
+ const child = spawn(command, args, { ...spawnOpts, stdio: ['pipe', 'pipe', 'pipe'] });
82
+ let stdout = '';
83
+ let stderr = '';
84
+ let stdoutLen = 0;
85
+ let stderrLen = 0;
86
+ child.stdout.on('data', chunk => {
87
+ const str = chunk.toString();
88
+ stdoutLen += str.length;
89
+ if (stdoutLen <= maxBuffer) stdout += str;
90
+ });
91
+ child.stderr.on('data', chunk => {
92
+ const str = chunk.toString();
93
+ stderrLen += str.length;
94
+ if (stderrLen <= maxBuffer) stderr += str;
95
+ });
96
+ child.on('error', reject);
97
+ child.on('close', code => {
98
+ if (code !== 0) {
99
+ const err = new Error(`Command failed: ${command} ${args.join(' ')}\n${stderr}`);
100
+ err.code = code;
101
+ err.stdout = stdout;
102
+ err.stderr = stderr;
103
+ reject(err);
104
+ } else {
105
+ resolve({ stdout, stderr });
106
+ }
107
+ });
108
+ if (input != null) {
109
+ child.stdin.write(input);
110
+ child.stdin.end();
111
+ } else {
112
+ child.stdin.end();
113
+ }
114
+ });
115
+ };
61
116
 
62
117
  /**
63
118
  * Truncate content in the middle, keeping start and end
@@ -1302,6 +1357,7 @@ export const utils = {
1302
1357
  formatCost,
1303
1358
  escapeMarkdown,
1304
1359
  getToolIcon,
1360
+ execFileAsync,
1305
1361
  CONFIG,
1306
1362
  };
1307
1363
 
@@ -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
+ }