@link-assistant/hive-mind 1.45.1 → 1.46.0

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,25 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.46.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d9721c0: Add work session completion notifications and isolation mode to Telegram bot
8
+
9
+ Session notifications:
10
+ - Tracks sessions started by `/solve` and `/hive` commands
11
+ - Monitors sessions every 30 seconds and sends completion notifications
12
+ - Sends notification with session name, duration, URL, and exit status
13
+ - Persistent session tracking via ExecutionStore from start-command
14
+
15
+ Isolation mode (`--isolation` option, experimental):
16
+ - New `--isolation` flag for Telegram bot: `screen`, `tmux`, or `docker`
17
+ - Uses `$` CLI from link-foundation/start with GUID-based session tracking
18
+ - Tracks session completion via `$ --status <uuid>` for reliable detection
19
+ - Solve queue supports isolation-aware execution and process counting
20
+ - Each isolated session gets a unique UUID for unambiguous tracking
21
+ - Without `--isolation`, uses existing `start-screen` command (unchanged)
22
+
3
23
  ## 1.45.1
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.45.1",
3
+ "version": "1.46.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Isolation Runner for Telegram bot
3
+ *
4
+ * Executes commands using the `$` CLI from start-command with isolation backends
5
+ * (screen, tmux, docker). Uses GUIDs for unique session tracking and
6
+ * `$ --status <uuid>` for reliable completion detection.
7
+ *
8
+ * Uses command-stream library to invoke the globally-installed `$` CLI,
9
+ * following the same pattern as claude.lib.mjs, agent.lib.mjs, etc.
10
+ *
11
+ * @see https://github.com/link-foundation/start
12
+ * @see https://github.com/link-assistant/hive-mind/issues/380
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+
17
+ if (typeof use === 'undefined') {
18
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
19
+ }
20
+
21
+ const { $ } = await use('command-stream');
22
+
23
+ // Valid isolation backends
24
+ const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
25
+
26
+ /**
27
+ * Generate a UUID v4 for unique session identification
28
+ * @returns {string} UUID v4 string
29
+ */
30
+ export function generateSessionId() {
31
+ return crypto.randomUUID();
32
+ }
33
+
34
+ /**
35
+ * Find the `$` CLI binary path
36
+ * @returns {Promise<string|null>} Path to `$` binary or null
37
+ */
38
+ async function findStartCommandBinary() {
39
+ try {
40
+ const result = await $`which $`;
41
+ const path = result.stdout?.toString().trim() || '';
42
+ return path || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Execute a command with isolation via `$` from start-command
50
+ *
51
+ * @param {string} command - The command to run (e.g., 'solve')
52
+ * @param {string[]} args - Arguments for the command
53
+ * @param {Object} options - Isolation options
54
+ * @param {string} options.backend - Isolation backend: 'screen', 'tmux', or 'docker'
55
+ * @param {string} [options.sessionId] - UUID for session tracking (auto-generated if not provided)
56
+ * @param {boolean} [options.verbose] - Enable verbose logging
57
+ * @returns {Promise<{success: boolean, sessionId: string, output: string, error?: string, warning?: string}>}
58
+ */
59
+ export async function executeWithIsolation(command, args, options = {}) {
60
+ const { backend, verbose = false } = options;
61
+ const sessionId = options.sessionId || generateSessionId();
62
+
63
+ if (!VALID_ISOLATION_BACKENDS.includes(backend)) {
64
+ return {
65
+ success: false,
66
+ sessionId,
67
+ output: '',
68
+ error: `Invalid isolation backend: '${backend}'. Must be one of: ${VALID_ISOLATION_BACKENDS.join(', ')}`,
69
+ };
70
+ }
71
+
72
+ const binPath = await findStartCommandBinary();
73
+ if (!binPath) {
74
+ return {
75
+ success: false,
76
+ sessionId,
77
+ output: '',
78
+ warning: '⚠️ WARNING: start-command ($) not found in PATH\nPlease install: npm install -g start-command',
79
+ error: 'start-command ($) not found',
80
+ };
81
+ }
82
+
83
+ if (verbose) {
84
+ console.log(`[VERBOSE] isolation-runner: Using $ binary at: ${binPath}`);
85
+ console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
86
+ }
87
+
88
+ // Build arguments as array for the $ CLI:
89
+ // $ --isolated <backend> --detached --session <sessionId> -- <command> <args...>
90
+ const argsStr = args.join(' ');
91
+
92
+ if (verbose) {
93
+ console.log(`[VERBOSE] isolation-runner: $ --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`);
94
+ }
95
+
96
+ try {
97
+ const result = await $({ mirror: false })`${binPath} --isolated ${backend} --detached --session ${sessionId} -- ${command} ${argsStr}`;
98
+
99
+ const stdout = result.stdout?.toString() || '';
100
+ const stderr = result.stderr?.toString() || '';
101
+ const output = stdout + (stderr ? '\n' + stderr : '');
102
+
103
+ if (verbose) {
104
+ console.log(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
105
+ }
106
+
107
+ return {
108
+ success: true,
109
+ sessionId,
110
+ output: output.trim(),
111
+ };
112
+ } catch (error) {
113
+ const stdout = error.stdout?.toString() || '';
114
+ const stderr = error.stderr?.toString() || '';
115
+ const output = stdout + stderr;
116
+
117
+ if (verbose) {
118
+ console.error(`[VERBOSE] isolation-runner: Error: ${error.message}`);
119
+ console.error(`[VERBOSE] isolation-runner: Output: ${output.substring(0, 500)}`);
120
+ }
121
+
122
+ return {
123
+ success: false,
124
+ sessionId,
125
+ output: output.trim(),
126
+ error: error.message,
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Query the status of an isolated session via `$ --status <uuid>`
133
+ *
134
+ * @param {string} sessionId - UUID of the session to check
135
+ * @param {boolean} [verbose] - Enable verbose logging
136
+ * @returns {Promise<{exists: boolean, status: string|null, exitCode: number|null, raw: string}>}
137
+ */
138
+ export async function querySessionStatus(sessionId, verbose = false) {
139
+ const binPath = await findStartCommandBinary();
140
+ if (!binPath) {
141
+ if (verbose) {
142
+ console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
143
+ }
144
+ return { exists: false, status: null, exitCode: null, raw: '' };
145
+ }
146
+
147
+ try {
148
+ const result = await $({ mirror: false })`${binPath} --status ${sessionId} --output-format json`;
149
+
150
+ const stdout = result.stdout?.toString().trim() || '';
151
+
152
+ if (verbose) {
153
+ console.log(`[VERBOSE] isolation-runner: Status query result: ${stdout.substring(0, 300)}`);
154
+ }
155
+
156
+ try {
157
+ const data = JSON.parse(stdout);
158
+ return {
159
+ exists: true,
160
+ status: data.status || null,
161
+ exitCode: data.exitCode !== undefined ? data.exitCode : null,
162
+ raw: stdout,
163
+ };
164
+ } catch {
165
+ // If JSON parsing fails, try text-based detection
166
+ const isExecuting = stdout.includes('executing');
167
+ const isExecuted = stdout.includes('executed');
168
+ return {
169
+ exists: isExecuting || isExecuted,
170
+ status: isExecuting ? 'executing' : isExecuted ? 'executed' : null,
171
+ exitCode: null,
172
+ raw: stdout,
173
+ };
174
+ }
175
+ } catch (error) {
176
+ if (verbose) {
177
+ console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
178
+ }
179
+ return { exists: false, status: null, exitCode: null, raw: '' };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Check if an isolated session is still running
185
+ *
186
+ * @param {string} sessionId - UUID of the session
187
+ * @param {boolean} [verbose] - Enable verbose logging
188
+ * @returns {Promise<boolean>} True if session is still executing
189
+ */
190
+ export async function isSessionRunning(sessionId, verbose = false) {
191
+ const result = await querySessionStatus(sessionId, verbose);
192
+ return result.exists && result.status === 'executing';
193
+ }
194
+
195
+ /**
196
+ * Validate that an isolation backend value is valid
197
+ * @param {string} backend - Backend value to validate
198
+ * @returns {boolean}
199
+ */
200
+ export function isValidIsolationBackend(backend) {
201
+ return VALID_ISOLATION_BACKENDS.includes(backend);
202
+ }
203
+
204
+ export { VALID_ISOLATION_BACKENDS };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Session monitoring for Telegram bot
3
+ *
4
+ * Tracks active sessions (screen-based or isolation-based) and sends
5
+ * notifications when they complete.
6
+ *
7
+ * Two tracking modes:
8
+ * 1. Screen mode (default): Uses `screen -ls` to detect session completion
9
+ * 2. Isolation mode: Uses `$ --status <uuid>` from start-command CLI for reliable tracking
10
+ *
11
+ * Session state is stored in-memory. The `$` CLI (start-command) is accessed
12
+ * purely via its CLI interface, not as a library dependency.
13
+ *
14
+ * @see https://github.com/link-foundation/start
15
+ * @see https://github.com/link-assistant/hive-mind/issues/380
16
+ */
17
+
18
+ import { promisify } from 'util';
19
+ import { exec as execCallback } from 'child_process';
20
+
21
+ const exec = promisify(execCallback);
22
+
23
+ // Lazy import for isolation runner (only when needed)
24
+ let _querySessionStatus = null;
25
+ async function getQuerySessionStatus() {
26
+ if (!_querySessionStatus) {
27
+ const mod = await import('./isolation-runner.lib.mjs');
28
+ _querySessionStatus = mod.querySessionStatus;
29
+ }
30
+ return _querySessionStatus;
31
+ }
32
+
33
+ // In-memory session store
34
+ const activeSessions = new Map();
35
+
36
+ /**
37
+ * Check if a screen session exists
38
+ * @param {string} sessionName - Name of the screen session to check
39
+ * @returns {Promise<boolean>} True if session exists, false otherwise
40
+ */
41
+ export async function checkScreenSessionExists(sessionName) {
42
+ try {
43
+ const { stdout } = await exec('screen -ls');
44
+ return stdout.includes(sessionName);
45
+ } catch {
46
+ // screen -ls returns exit code 1 when no sessions exist
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Check if an isolated session is still running using $ --status
53
+ * @param {string} sessionId - UUID of the isolated session
54
+ * @param {boolean} verbose - Whether to log verbose output
55
+ * @returns {Promise<boolean>} True if session is still running
56
+ */
57
+ async function checkIsolatedSessionRunning(sessionId, verbose = false) {
58
+ try {
59
+ const queryStatus = await getQuerySessionStatus();
60
+ const result = await queryStatus(sessionId, verbose);
61
+ return result.exists && result.status === 'executing';
62
+ } catch (error) {
63
+ if (verbose) {
64
+ console.error(`[VERBOSE] Error checking isolated session ${sessionId}: ${error.message}`);
65
+ }
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Get the exit code of a completed isolated session
72
+ * @param {string} sessionId - UUID of the isolated session
73
+ * @param {boolean} verbose - Whether to log verbose output
74
+ * @returns {Promise<number|null>} Exit code or null if unknown
75
+ */
76
+ async function getIsolatedSessionExitCode(sessionId, verbose = false) {
77
+ try {
78
+ const queryStatus = await getQuerySessionStatus();
79
+ const result = await queryStatus(sessionId, verbose);
80
+ if (result.exists && result.status === 'executed') {
81
+ return result.exitCode;
82
+ }
83
+ return null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Track a new session for completion monitoring
91
+ *
92
+ * @param {string} sessionName - Name of the screen session or isolation session UUID
93
+ * @param {Object} sessionInfo - Session metadata
94
+ * @param {number} sessionInfo.chatId - Telegram chat ID to notify
95
+ * @param {number} [sessionInfo.messageId] - Telegram message ID to update on completion
96
+ * @param {Date} sessionInfo.startTime - When the session started
97
+ * @param {string} sessionInfo.url - GitHub URL being processed
98
+ * @param {string} sessionInfo.command - Command type (solve/hive)
99
+ * @param {string} [sessionInfo.isolationBackend] - Isolation backend if using isolation mode
100
+ * @param {string} [sessionInfo.sessionId] - UUID for isolation-based sessions
101
+ * @param {boolean} verbose - Whether to log verbose output
102
+ */
103
+ export function trackSession(sessionName, sessionInfo, verbose = false) {
104
+ activeSessions.set(sessionName, sessionInfo);
105
+ if (verbose) {
106
+ const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'screen';
107
+ console.log(`[VERBOSE] Session ${sessionName} tracked in memory (mode: ${mode})`);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Get the number of active sessions being tracked
113
+ * @param {boolean} verbose - Whether to log verbose output
114
+ * @returns {number} Number of active sessions
115
+ */
116
+ export function getActiveSessionCount(verbose = false) {
117
+ if (verbose) {
118
+ console.log(`[VERBOSE] Active sessions: ${activeSessions.size}`);
119
+ }
120
+ return activeSessions.size;
121
+ }
122
+
123
+ /**
124
+ * Get all active sessions
125
+ * @param {boolean} verbose - Whether to log verbose output
126
+ * @returns {Array<{sessionName: string, sessionInfo: Object}>} Array of active sessions
127
+ */
128
+ function getActiveSessions(verbose = false) {
129
+ const sessions = [];
130
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
131
+ sessions.push({ sessionName, sessionInfo });
132
+ }
133
+ if (verbose) {
134
+ console.log(`[VERBOSE] Retrieved ${sessions.length} active session(s)`);
135
+ }
136
+ return sessions;
137
+ }
138
+
139
+ /**
140
+ * Remove a session from tracking
141
+ * @param {string} sessionName - Name of the session to remove
142
+ * @param {boolean} verbose - Whether to log verbose output
143
+ */
144
+ function completeSession(sessionName, exitCode = 0, verbose = false) {
145
+ activeSessions.delete(sessionName);
146
+ if (verbose) {
147
+ console.log(`[VERBOSE] Session ${sessionName} removed from tracking (exit: ${exitCode})`);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Monitor active sessions and send notifications when they complete
153
+ * @param {Object} bot - Telegraf bot instance for sending messages
154
+ * @param {boolean} verbose - Whether to log verbose output
155
+ */
156
+ export async function monitorSessions(bot, verbose = false) {
157
+ const sessions = getActiveSessions(verbose);
158
+
159
+ if (sessions.length === 0) {
160
+ return;
161
+ }
162
+
163
+ if (verbose) {
164
+ console.log(`[VERBOSE] Checking ${sessions.length} active session(s)...`);
165
+ }
166
+
167
+ for (const { sessionName, sessionInfo } of sessions) {
168
+ let stillRunning;
169
+ let exitCode = null;
170
+
171
+ if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
172
+ // Isolation mode: use $ --status for reliable tracking
173
+ stillRunning = await checkIsolatedSessionRunning(sessionInfo.sessionId, verbose);
174
+ if (!stillRunning) {
175
+ exitCode = await getIsolatedSessionExitCode(sessionInfo.sessionId, verbose);
176
+ }
177
+ } else {
178
+ // Screen mode: use screen -ls for detection
179
+ stillRunning = await checkScreenSessionExists(sessionName);
180
+ }
181
+
182
+ if (!stillRunning) {
183
+ console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
184
+
185
+ try {
186
+ const endTime = new Date();
187
+ const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
188
+ const duration = Math.round((endTime - startTime) / 1000);
189
+ const minutes = Math.floor(duration / 60);
190
+ const seconds = duration % 60;
191
+
192
+ const statusEmoji = exitCode === null || exitCode === 0 ? '✅' : '❌';
193
+ const statusText = exitCode === null || exitCode === 0 ? 'Completed' : `Failed (exit code: ${exitCode})`;
194
+ const isolationInfo = sessionInfo.isolationBackend ? `\n🔒 Isolation: ${sessionInfo.isolationBackend}` : '';
195
+
196
+ let message = `${statusEmoji} *Work Session ${statusText}*\n\n`;
197
+ message += `📊 Session: \`${sessionName}\`\n`;
198
+ message += `⏱️ Duration: ${minutes}m ${seconds}s\n`;
199
+ message += `🔗 URL: ${sessionInfo.url}${isolationInfo}\n\n`;
200
+ message += `The work session has finished. You can now review the results.`;
201
+
202
+ // Update the original reply message if messageId is available, otherwise send new message
203
+ if (sessionInfo.messageId) {
204
+ await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
205
+ } else {
206
+ await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
207
+ }
208
+
209
+ completeSession(sessionName, exitCode || 0, verbose);
210
+ } catch (error) {
211
+ console.error(`Failed to send completion notification for ${sessionName}:`, error);
212
+ completeSession(sessionName, 1, verbose);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Start the session monitoring interval
220
+ * @param {Object} bot - Telegraf bot instance for sending messages
221
+ * @param {boolean} verbose - Whether to log verbose output
222
+ * @param {number} intervalMs - Monitoring interval in milliseconds (default: 30000)
223
+ * @returns {NodeJS.Timer} The interval timer (can be cleared with clearInterval)
224
+ */
225
+ export function startSessionMonitoring(bot, verbose = false, intervalMs = 30000) {
226
+ const timer = setInterval(() => monitorSessions(bot, verbose), intervalMs);
227
+ console.log(`📊 Session monitoring started (checking every ${intervalMs / 1000} seconds, storage: in-memory)`);
228
+ return timer;
229
+ }
230
+
231
+ /**
232
+ * Get statistics about session tracking
233
+ * @param {boolean} verbose - Whether to log verbose output
234
+ * @returns {Object} Statistics object
235
+ */
236
+ export function getSessionStats(verbose = false) {
237
+ const sessions = Array.from(activeSessions.values());
238
+ const isolated = sessions.filter(s => s.isolationBackend);
239
+
240
+ if (verbose) {
241
+ console.log(`[VERBOSE] Session stats: ${sessions.length} total, ${isolated.length} isolated`);
242
+ }
243
+
244
+ return {
245
+ total: activeSessions.size,
246
+ executing: activeSessions.size,
247
+ executed: 0,
248
+ successful: 0,
249
+ failed: 0,
250
+ isolated: isolated.length,
251
+ storageType: 'in-memory',
252
+ };
253
+ }
@@ -23,27 +23,18 @@ const { loadLenvConfig } = await import('./lenv-reader.lib.mjs');
23
23
 
24
24
  const dotenvxModule = await use('@dotenvx/dotenvx');
25
25
  const dotenvx = dotenvxModule.default || dotenvxModule;
26
-
27
26
  const getenvModule = await use('getenv');
28
- // Node 24 CJS/ESM interop may return the whole module object instead of the function directly
29
27
  const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
30
28
 
31
- // Load .env configuration as base
32
- // quiet: true suppresses info messages, ignore: ['MISSING_ENV_FILE'] suppresses error when .env doesn't exist
33
- // This makes .env file optional (issue #1318)
29
+ // Load .env/.lenv configuration (issue #1318)
34
30
  dotenvx.config({ quiet: true, ignore: ['MISSING_ENV_FILE'] });
35
-
36
- // Load .lenv configuration (if exists)
37
- // .lenv overrides .env
38
31
  loadLenvConfig({ override: true, quiet: true });
39
32
 
40
33
  const yargsModule = await use('yargs@17.7.2');
41
34
  const yargs = yargsModule.default || yargsModule;
42
35
  const helpersModuleBot = await use('yargs@17.7.2/helpers');
43
- // Node 24 CJS/ESM interop may return the whole module object instead of named exports directly
44
36
  const _helpersBot = helpersModuleBot.default || helpersModuleBot;
45
37
  const hideBin = _helpersBot.hideBin || (argv => argv.slice(2));
46
- // Import yargs configurations, GitHub utilities, and telegram helpers
47
38
  const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
48
39
  const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
49
40
  const { parseGitHubUrl } = await import('./github.lib.mjs');
@@ -55,8 +46,8 @@ const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCha
55
46
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
56
47
  const { isChatStopped, getChatStopInfo, getStoppedChatRejectMessage, DEFAULT_STOP_REASON } = await import('./telegram-start-stop-command.lib.mjs');
57
48
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
58
- // Import bot launcher with exponential backoff retry (issue #1240)
59
49
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
50
+ const { trackSession, startSessionMonitoring } = await import('./session-monitor.lib.mjs');
60
51
 
61
52
  const config = yargs(hideBin(process.argv))
62
53
  .usage('Usage: hive-telegram-bot [options]')
@@ -118,6 +109,7 @@ const config = yargs(hideBin(process.argv))
118
109
  alias: 'v',
119
110
  default: getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true',
120
111
  })
112
+ .option('isolation', { type: 'string', description: 'Experimental: isolation backend (screen/tmux/docker)', default: getenv('TELEGRAM_ISOLATION', '') })
121
113
  .help('h')
122
114
  .alias('h', 'help')
123
115
  .parserConfiguration({
@@ -127,65 +119,52 @@ const config = yargs(hideBin(process.argv))
127
119
  .strict() // Enable strict mode to reject unknown options (consistent with solve.mjs and hive.mjs)
128
120
  .parse();
129
121
 
130
- // Load configuration from --configuration option if provided
131
- // This allows users to pass environment variables via command line
132
- //
133
- // Complete configuration priority order (highest priority last):
134
- // 1. .env (base configuration, loaded first - already loaded above at line 24)
135
- // 2. .lenv (overrides .env - already loaded above at line 28)
136
- // 3. yargs CLI options parsed above (lines 41-102) use getenv() for defaults,
137
- // which reads from process.env populated by .env and .lenv
138
- // 4. --configuration option (overrides process.env, affecting getenv() calls below)
139
- // 5. Final resolution (lines 116+): CLI option values > environment variables
140
- // Pattern: config.X || getenv('VAR') means CLI options have highest priority
122
+ // Configuration priority: CLI option > --configuration LINO > .lenv > .env
141
123
  if (config.configuration) {
142
124
  loadLenvConfig({ configuration: config.configuration, override: true, quiet: true });
143
125
  }
144
126
 
145
- // After loading configuration, resolve final values
146
- // Priority: CLI option > environment variable
147
127
  const BOT_TOKEN = config.token || getenv('TELEGRAM_BOT_TOKEN', '');
148
128
  const VERBOSE = config.verbose || getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true';
149
-
150
129
  if (!BOT_TOKEN) {
151
- console.error('Error: TELEGRAM_BOT_TOKEN environment variable or --token option is not set');
152
- console.error('Please set it with: export TELEGRAM_BOT_TOKEN=your_bot_token');
153
- console.error('Or use: hive-telegram-bot --token your_bot_token');
130
+ console.error('Error: TELEGRAM_BOT_TOKEN not set. Use --token or TELEGRAM_BOT_TOKEN env var.');
154
131
  process.exit(1);
155
132
  }
156
133
 
157
- // After loading configuration, resolve final values from environment or config
158
- // Priority: CLI option > environment variable (from .lenv or .env)
159
- // NOTE: This section moved BEFORE loading telegraf for faster dry-run mode (issue #801)
134
+ // Resolve final config values (CLI option > environment variable)
160
135
  const resolvedAllowedChats = config.allowedChats || getenv('TELEGRAM_ALLOWED_CHATS', '');
161
136
  const allowedChats = resolvedAllowedChats ? lino.parseNumericIds(resolvedAllowedChats) : null;
162
137
 
163
138
  // Parse allowed topics (chatId:topicId pairs in Links Notation)
164
139
  const resolvedAllowedTopics = config.allowedTopics || getenv('TELEGRAM_ALLOWED_TOPICS', '');
165
140
  const allowedTopics = resolvedAllowedTopics ? lino.parseLinks(resolvedAllowedTopics) : null;
166
-
167
- // Parse override options
168
141
  const resolvedSolveOverrides = config.solveOverrides || getenv('TELEGRAM_SOLVE_OVERRIDES', '');
169
142
  const solveOverrides = resolvedSolveOverrides
170
143
  ? lino
171
144
  .parseStringValues(resolvedSolveOverrides)
172
- .map(line => line.trim())
173
- .filter(line => line)
145
+ .map(l => l.trim())
146
+ .filter(l => l)
174
147
  : [];
175
-
176
148
  const resolvedHiveOverrides = config.hiveOverrides || getenv('TELEGRAM_HIVE_OVERRIDES', '');
177
149
  const hiveOverrides = resolvedHiveOverrides
178
150
  ? lino
179
151
  .parseStringValues(resolvedHiveOverrides)
180
- .map(line => line.trim())
181
- .filter(line => line)
152
+ .map(l => l.trim())
153
+ .filter(l => l)
182
154
  : [];
183
-
184
- // Command enable/disable flags
185
- // Note: yargs automatically supports --no-solve and --no-hive for negation
186
- // Priority: CLI option > environment variable
187
155
  const solveEnabled = config.solve;
188
156
  const hiveEnabled = config.hive;
157
+ // Isolation mode (experimental): uses `$` from start-command with specified backend
158
+ const ISOLATION_BACKEND = (config.isolation || getenv('TELEGRAM_ISOLATION', '')).trim().toLowerCase();
159
+ let isolationRunner = null;
160
+ if (ISOLATION_BACKEND) {
161
+ if (!['screen', 'tmux', 'docker'].includes(ISOLATION_BACKEND)) {
162
+ console.error(`Error: Invalid --isolation value '${ISOLATION_BACKEND}'. Must be: screen, tmux, or docker`);
163
+ process.exit(1);
164
+ }
165
+ console.log(`🔒 Isolation mode enabled: ${ISOLATION_BACKEND} (experimental)`);
166
+ isolationRunner = await import('./isolation-runner.lib.mjs');
167
+ }
189
168
 
190
169
  // Validate solve overrides early using solve's yargs config
191
170
  // Only validate if solve command is enabled
@@ -607,29 +586,35 @@ async function safeReply(ctx, text, options = {}) {
607
586
  }
608
587
  }
609
588
 
610
- // Execute a start-screen command and update the initial message with the result
589
+ // Execute a command via isolation mode ($ from start-command) or start-screen, then update message
611
590
  async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock) {
612
- const result = await executeStartScreen(commandName, args);
613
- const { chat, message_id } = startingMessage;
614
-
615
- // Safely edit message - catch errors to prevent stuck "Starting..." messages (issue #1062)
591
+ const { chat, message_id: msgId } = startingMessage;
616
592
  const safeEdit = async text => {
617
593
  try {
618
- await ctx.telegram.editMessageText(chat.id, message_id, undefined, text, { parse_mode: 'Markdown' });
594
+ await ctx.telegram.editMessageText(chat.id, msgId, undefined, text, { parse_mode: 'Markdown' });
619
595
  } catch (e) {
620
596
  console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
621
597
  }
622
598
  };
623
-
624
- if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
625
-
626
- if (result.success) {
627
- const match = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/);
628
- const session = match ? match[1] : 'unknown';
629
- await safeEdit(`✅ ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command started successfully!\n\n📊 Session: \`${session}\`\n\n${infoBlock}`);
599
+ let result,
600
+ session,
601
+ 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);
630
609
  } else {
631
- await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
610
+ result = await executeStartScreen(commandName, args);
611
+ const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
612
+ session = match ? match[1] : 'unknown';
613
+ if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName }, VERBOSE);
632
614
  }
615
+ if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
616
+ if (result.success) await safeEdit(`✅ ${commandName.charAt(0).toUpperCase() + commandName.slice(1)} command started successfully!\n\n📊 Session: \`${session}\`${extraInfo}\n\n${infoBlock}\n\n🔔 You will receive a notification when the session finishes.`);
617
+ else await safeEdit(`❌ Error executing ${commandName} command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``);
633
618
  }
634
619
 
635
620
  bot.command('help', async ctx => {
@@ -707,6 +692,9 @@ bot.command('help', async ctx => {
707
692
  message += '*/help* - Show this help message\n';
708
693
  message += '*/stop* - Stop accepting new tasks (owner only)\n';
709
694
  message += '*/start* - Resume accepting tasks (owner only)\n\n';
695
+ message += '🔔 *Session Notifications:* The bot monitors sessions and notifies when they complete.\n';
696
+ if (ISOLATION_BACKEND) message += `🔒 *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
697
+ message += '\n';
710
698
  message += '⚠️ *Note:* /solve, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats.\n\n';
711
699
  message += '🔧 *Common Options:*\n';
712
700
  message += `• \`--model <model>\` or \`-m\` - ${buildModelOptionDescription()}\n`;
@@ -1043,7 +1031,17 @@ async function handleSolveCommand(ctx) {
1043
1031
  if (check.reason) queueMessage += `\n\n⏳ Waiting: ${escapeMarkdown(check.reason)}`;
1044
1032
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
1045
1033
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
1046
- if (!solveQueue.executeCallback) solveQueue.executeCallback = createQueueExecuteCallback(executeStartScreen);
1034
+ 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));
1044
+ }
1047
1045
  }
1048
1046
  }
1049
1047
 
@@ -1457,6 +1455,9 @@ launchBotWithRetry(
1457
1455
 
1458
1456
  console.log('[VERBOSE] Send a message to the bot to test message reception');
1459
1457
  }
1458
+
1459
+ // Start session monitoring - check for completed sessions every 30 seconds
1460
+ startSessionMonitoring(bot, VERBOSE);
1460
1461
  })
1461
1462
  .catch(error => {
1462
1463
  console.error('❌ Failed to start bot:', error);
@@ -1351,14 +1351,44 @@ export function resetSolveQueue() {
1351
1351
  /**
1352
1352
  * Create an execute callback for the queue
1353
1353
  * @param {Function} executeStartScreen - Function to execute start-screen command
1354
+ * @param {Function} [trackSessionFn] - Optional function to track session for completion notifications
1354
1355
  * @returns {Function} Execute callback for queue items
1355
1356
  */
1356
- export function createQueueExecuteCallback(executeStartScreen) {
1357
+ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1357
1358
  return async item => {
1358
- return await executeStartScreen('solve', item.args);
1359
+ const result = await executeStartScreen('solve', item.args);
1360
+ if (trackSessionFn && result.success) {
1361
+ const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
1362
+ const session = match ? match[1] : null;
1363
+ if (session) {
1364
+ trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve' });
1365
+ }
1366
+ }
1367
+ return result;
1359
1368
  };
1360
1369
  }
1361
1370
 
1371
+ /**
1372
+ * Get count of running isolated sessions tracked via ExecutionStore
1373
+ * When isolation mode is enabled, this replaces pgrep-based process detection
1374
+ * for more reliable task counting.
1375
+ *
1376
+ * @param {boolean} verbose - Whether to log verbose output
1377
+ * @returns {Promise<{count: number, sessions: string[]}>}
1378
+ */
1379
+ export async function getRunningIsolatedSessions(verbose = false) {
1380
+ try {
1381
+ const { getActiveSessionCount } = await import('./session-monitor.lib.mjs');
1382
+ const count = getActiveSessionCount(verbose);
1383
+ return { count, sessions: [] };
1384
+ } catch (error) {
1385
+ if (verbose) {
1386
+ console.error(`[VERBOSE] /solve_queue error getting isolated sessions:`, error.message);
1387
+ }
1388
+ return { count: 0, sessions: [] };
1389
+ }
1390
+ }
1391
+
1362
1392
  export default {
1363
1393
  SolveQueue,
1364
1394
  SolveQueueItem,
@@ -1367,6 +1397,7 @@ export default {
1367
1397
  getRunningProcesses,
1368
1398
  getRunningClaudeProcesses,
1369
1399
  getRunningAgentProcesses,
1400
+ getRunningIsolatedSessions,
1370
1401
  createQueueExecuteCallback,
1371
1402
  formatDuration,
1372
1403
  QUEUE_CONFIG,