@link-assistant/hive-mind 1.64.3 → 1.64.4

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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.64.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 20f5898: Add `/stop <UUID>` and reply-to-message-with-UUID modes to the Telegram bot (#524). Sending `/stop <uuid>` (or replying with `/stop` to a message containing a UUID) forwards CTRL+C to the matching isolated `/solve` or `/hive` session via `$ --stop <uuid>` from link-foundation/start (link-foundation/start#112), so individual screen/tmux/docker sessions can be cancelled from Telegram. Mirrors the existing `/log` and `/terminal_watch` UUID-resolution pattern. Bare `/stop` retains its existing chat-pause behaviour (#1081).
8
+
3
9
  ## 1.64.3
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.64.3",
3
+ "version": "1.64.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -255,6 +255,55 @@ export async function querySessionStatus(sessionId, verbose = false) {
255
255
  }
256
256
  }
257
257
 
258
+ /**
259
+ * Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
260
+ *
261
+ * Wraps `$ --stop <uuid>` from start-command (link-foundation/start#112).
262
+ * Works for any isolation backend (screen, tmux, docker, …) — `$` knows the
263
+ * backend it launched with and forwards the interrupt accordingly.
264
+ *
265
+ * @param {string} sessionId - UUID of the session to stop
266
+ * @param {boolean} [verbose] - Enable verbose logging
267
+ * @returns {Promise<{success: boolean, output: string, error: string|null}>}
268
+ */
269
+ export async function stopIsolatedSession(sessionId, verbose = false) {
270
+ const binPath = await findStartCommandBinary();
271
+ if (!binPath) {
272
+ if (verbose) {
273
+ console.log('[VERBOSE] isolation-runner: Cannot stop session - $ binary not found');
274
+ }
275
+ return {
276
+ success: false,
277
+ output: '',
278
+ error: '`$` (start-command) binary not found on PATH. Install link-foundation/start to use /stop <UUID>.',
279
+ };
280
+ }
281
+
282
+ try {
283
+ const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
284
+ const stdout = result.stdout?.toString() || '';
285
+ const stderr = result.stderr?.toString() || '';
286
+ if (verbose) {
287
+ console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stdout: ${stdout.substring(0, 300)}`);
288
+ if (stderr) {
289
+ console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stderr: ${stderr.substring(0, 300)}`);
290
+ }
291
+ }
292
+ return { success: true, output: stdout || stderr, error: null };
293
+ } catch (error) {
294
+ const stderr = error?.stderr?.toString?.() || '';
295
+ const stdout = error?.stdout?.toString?.() || '';
296
+ if (verbose) {
297
+ console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} failed: ${error.message}`);
298
+ }
299
+ return {
300
+ success: false,
301
+ output: stdout,
302
+ error: stderr.trim() || error?.message || String(error),
303
+ };
304
+ }
305
+ }
306
+
258
307
  /**
259
308
  * Check if a screen session exists via `screen -ls`.
260
309
  * Used as a fallback when `$ --status` fails to find or correctly track
@@ -571,6 +571,7 @@ bot.command('help', async ctx => {
571
571
  message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
572
572
  message += '*/help* - Show this help message\n';
573
573
  message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
574
+ message += '*/stop* `<uuid>` - Send CTRL+C to an isolated solve/hive session (owner only). Also works as a reply to a message containing the UUID.\n';
574
575
  message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n';
575
576
  message += '*/terminal\\_watch* - Live-update an isolation session log (owner only). Usage: `/terminal_watch <uuid>` or reply with `/terminal_watch`\n\n';
576
577
  message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
@@ -10,10 +10,17 @@
10
10
  * - Graceful stop: existing queue items continue to process
11
11
  * - Read-only commands (/help, /limits, /version) remain available when stopped
12
12
  * - Write commands (/solve, /hive) are rejected when stopped
13
+ * - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
14
+ * matching isolated solve/hive session via `$ --stop <UUID>` from
15
+ * link-foundation/start (issue #524).
13
16
  *
14
17
  * @see https://github.com/link-assistant/hive-mind/issues/1081
18
+ * @see https://github.com/link-assistant/hive-mind/issues/524
19
+ * @see https://github.com/link-foundation/start/issues/112
15
20
  */
16
21
 
22
+ import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
23
+
17
24
  // Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
18
25
  const stoppedChats = new Map();
19
26
 
@@ -86,6 +93,30 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
86
93
  return `❌ ${commandName} command rejected.\n\n🚫 Reason: ${reason}\n\nUse /start to resume (chat owner only).`;
87
94
  }
88
95
 
96
+ /**
97
+ * Extract a session UUID for `/stop`. Priority:
98
+ * 1. UUID literal anywhere in the `/stop` message text.
99
+ * 2. UUID in the text/caption of the message being replied to.
100
+ *
101
+ * The `text` argument is the raw `/stop ...` command text. `repliedTo`, when
102
+ * present, is the Telegram message object that the user replied to with `/stop`.
103
+ *
104
+ * @param {string} text
105
+ * @param {Object|null|undefined} repliedTo
106
+ * @returns {{ sessionId: string|null, source: 'argument'|'reply'|null }}
107
+ */
108
+ export function extractStopSessionId(text, repliedTo) {
109
+ // Strip the leading `/stop` (or `/stop@botname`) before looking for a UUID,
110
+ // so we don't accidentally match digits inside the command name itself.
111
+ const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
112
+ const direct = extractSessionIdFromText(argText);
113
+ if (direct) return { sessionId: direct, source: 'argument' };
114
+ const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
115
+ const fromReply = extractSessionIdFromText(replyText);
116
+ if (fromReply) return { sessionId: fromReply, source: 'reply' };
117
+ return { sessionId: null, source: null };
118
+ }
119
+
89
120
  /**
90
121
  * Registers the /start and /stop command handlers with the bot
91
122
  * @param {Object} bot - The Telegraf bot instance
@@ -95,9 +126,13 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
95
126
  * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
96
127
  * @param {Function} options.isGroupChat - Function to check if chat is a group
97
128
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
129
+ * @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
130
+ * @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
131
+ * @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
98
132
  */
99
133
  export function registerStartStopCommands(bot, options) {
100
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
134
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
135
+ const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
101
136
 
102
137
  /**
103
138
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
@@ -145,9 +180,110 @@ export function registerStartStopCommands(bot, options) {
145
180
  return { valid: true, chatId };
146
181
  }
147
182
 
148
- // /stop command - stop accepting new tasks in this chat
149
- // Only accessible by chat owner (creator)
183
+ // /stop command. Two modes:
184
+ // 1. `/stop <UUID>` or reply-to-message-with-UUID forward CTRL+C to the
185
+ // matching isolated session via `$ --stop <UUID>` (issue #524).
186
+ // 2. bare `/stop` (optionally with a free-text reason) — pause new task
187
+ // acceptance for the chat (issue #1081).
188
+ // Only accessible by chat owner (creator) in both modes.
150
189
  bot.command('stop', async ctx => {
190
+ VERBOSE && console.log('[VERBOSE] /stop command received');
191
+ if (isOldMessage(ctx)) {
192
+ VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
193
+ return;
194
+ }
195
+
196
+ // Detect UUID modes BEFORE the forwarded/reply rejection used by the
197
+ // chat-level stop, because the UUID-from-reply mode is intentionally a
198
+ // reply (issue #524).
199
+ const message = ctx.message;
200
+ const repliedTo = message?.reply_to_message || null;
201
+ const { sessionId, source } = extractStopSessionId(message?.text || '', repliedTo);
202
+
203
+ if (sessionId) {
204
+ VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${source})`);
205
+ // Reuse the same auth model as /log: must be chat owner in groups; in
206
+ // private DMs the user is implicitly the owner of their own chat.
207
+ const chatId = ctx.chat?.id;
208
+ const chatType = ctx.chat?.type;
209
+ if (chatType !== 'private') {
210
+ if (!isGroupChat(ctx)) {
211
+ await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
212
+ return;
213
+ }
214
+ if (!isChatAuthorized(chatId)) {
215
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
216
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
217
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
218
+ return;
219
+ }
220
+ }
221
+ try {
222
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
223
+ if (!member || member.status !== 'creator') {
224
+ VERBOSE && console.log('[VERBOSE] /stop <UUID> ignored: user is not chat owner');
225
+ await ctx.reply('❌ /stop <UUID> is only available to the chat owner.', { reply_to_message_id: message.message_id });
226
+ return;
227
+ }
228
+ } catch (error) {
229
+ console.error('[ERROR] /stop <UUID>: getChatMember failed:', error);
230
+ await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
231
+ return;
232
+ }
233
+ }
234
+
235
+ const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
236
+ parse_mode: 'Markdown',
237
+ reply_to_message_id: message.message_id,
238
+ });
239
+
240
+ let result;
241
+ try {
242
+ result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
243
+ } catch (error) {
244
+ console.error('[ERROR] /stop <UUID>: stopIsolatedSession threw:', error);
245
+ result = { success: false, output: '', error: error?.message || String(error) };
246
+ }
247
+
248
+ const trimmedOutput = (result.output || '').toString().trim();
249
+ const trimmedError = (result.error || '').toString().trim();
250
+ const lines = [];
251
+ if (result.success) {
252
+ lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
253
+ lines.push('');
254
+ lines.push('The session should terminate shortly.');
255
+ if (trimmedOutput) {
256
+ lines.push('');
257
+ lines.push('```');
258
+ lines.push(trimmedOutput.slice(0, 1000));
259
+ lines.push('```');
260
+ }
261
+ } else {
262
+ lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
263
+ if (trimmedError) {
264
+ lines.push('');
265
+ lines.push('```');
266
+ lines.push(trimmedError.slice(0, 1000));
267
+ lines.push('```');
268
+ }
269
+ }
270
+
271
+ try {
272
+ await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
273
+ } catch (error) {
274
+ console.error('[ERROR] /stop <UUID>: editMessageText failed, falling back to reply:', error);
275
+ await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
276
+ }
277
+ return;
278
+ }
279
+
280
+ // No UUID — fall through to the chat-level pause flow. That flow rejects
281
+ // forwards/replies on purpose (#1081) so a stray reply doesn't pause the chat.
282
+ if (isForwardedOrReply(ctx)) {
283
+ VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
284
+ return;
285
+ }
286
+
151
287
  const check = await validateOwnerCommand(ctx, '/stop');
152
288
  if (!check.valid) return;
153
289
  const chatId = check.chatId;