@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 +12 -0
- package/package.json +1 -1
- package/src/interactive-mode.lib.mjs +61 -5
- package/src/telegram-bot.mjs +26 -26
- package/src/telegram-isolation.lib.mjs +88 -0
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
|
@@ -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
|
|
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
|
-
|
|
59
|
-
import {
|
|
60
|
-
|
|
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
|
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
603
|
-
|
|
604
|
-
VERBOSE && console.log(`[VERBOSE] Using isolation (${
|
|
605
|
-
result = await
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
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
|
+
}
|