@link-assistant/hive-mind 1.69.6 → 1.69.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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.69.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 2ea2bb7: Extend Telegram `/stop` to accept a GitHub issue or pull-request URL (passed as the argument or contained in the replied-to message). The bot looks the URL up in the in-memory solve queue and either cancels the queued item or forwards CTRL+C via `$ --stop <UUID>` to the running isolated session. The UUID flow from #524 and the chat-level pause flow from #1081 are preserved.
8
+
3
9
  ## 1.69.6
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.69.6",
3
+ "version": "1.69.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",
@@ -1057,7 +1057,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1057
1057
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1058
1058
  const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1059
1059
  registerTopCommand(bot, sharedCommandOpts);
1060
- registerStartStopCommands(bot, sharedCommandOpts);
1060
+ registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
1061
1061
  await registerLogCommand(bot, sharedCommandOpts);
1062
1062
  await registerTerminalWatchCommand(bot, sharedCommandOpts);
1063
1063
 
@@ -13,13 +13,21 @@
13
13
  * - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
14
14
  * matching isolated solve/hive session via `$ --stop <UUID>` from
15
15
  * link-foundation/start (issue #524).
16
+ * - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
17
+ * the URL up in the in-memory solve queue and either cancels the queued
18
+ * item or forwards CTRL+C to the running isolated session (issue #1780).
16
19
  *
17
20
  * @see https://github.com/link-assistant/hive-mind/issues/1081
18
21
  * @see https://github.com/link-assistant/hive-mind/issues/524
22
+ * @see https://github.com/link-assistant/hive-mind/issues/1780
19
23
  * @see https://github.com/link-foundation/start/issues/112
20
24
  */
21
25
 
22
26
  import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
27
+ import { parseGitHubUrl } from './github.lib.mjs';
28
+ import { cleanNonPrintableChars } from './telegram-markdown.lib.mjs';
29
+
30
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
31
 
24
32
  // Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
25
33
  const stoppedChats = new Map();
@@ -117,6 +125,66 @@ export function extractStopSessionId(text, repliedTo) {
117
125
  return { sessionId: null, source: null };
118
126
  }
119
127
 
128
+ /**
129
+ * Walk arbitrary text and return the first GitHub issue or pull-request URL
130
+ * found, or null. Tolerates multiple URLs (returns the first issue/pull URL
131
+ * in source order). Uses the same `parseGitHubUrl` validator as the rest of
132
+ * the bot so the result is always a normalized URL string.
133
+ *
134
+ * @param {string} text
135
+ * @returns {string|null}
136
+ */
137
+ function findFirstIssueOrPullUrl(text) {
138
+ if (!text || typeof text !== 'string') return null;
139
+ const cleaned = cleanNonPrintableChars(text);
140
+ for (const word of cleaned.split(/\s+/)) {
141
+ if (!word) continue;
142
+ const parsed = parseGitHubUrl(word);
143
+ if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
144
+ return parsed.normalized;
145
+ }
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * Extract the target of a `/stop` invocation. Returns the most specific
152
+ * target found among the four possible sources, in this priority order:
153
+ *
154
+ * 1. UUID in the `/stop` argument (kind='uuid', source='argument')
155
+ * 2. UUID in the replied-to message (kind='uuid', source='reply')
156
+ * 3. Issue/PR URL in the `/stop` argument (kind='url', source='argument')
157
+ * 4. Issue/PR URL in the replied-to text (kind='url', source='reply')
158
+ *
159
+ * UUIDs win over URLs because UUIDs are globally unique whereas a single
160
+ * issue URL can map to several in-flight requests if the user enqueued the
161
+ * same issue twice. Argument wins over reply because the argument is the
162
+ * more deliberate signal (the user explicitly typed it).
163
+ *
164
+ * @param {string} text - Raw `/stop ...` command text
165
+ * @param {Object|null|undefined} repliedTo - Telegram message object being replied to
166
+ * @returns {{ kind: 'uuid'|'url'|null, value: string|null, source: 'argument'|'reply'|null }}
167
+ * @see https://github.com/link-assistant/hive-mind/issues/1780
168
+ */
169
+ export function extractStopTarget(text, repliedTo) {
170
+ const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
171
+ const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
172
+
173
+ const argUuid = extractSessionIdFromText(argText);
174
+ if (argUuid) return { kind: 'uuid', value: argUuid, source: 'argument' };
175
+
176
+ const replyUuid = extractSessionIdFromText(replyText);
177
+ if (replyUuid) return { kind: 'uuid', value: replyUuid, source: 'reply' };
178
+
179
+ const argUrl = findFirstIssueOrPullUrl(argText);
180
+ if (argUrl) return { kind: 'url', value: argUrl, source: 'argument' };
181
+
182
+ const replyUrl = findFirstIssueOrPullUrl(replyText);
183
+ if (replyUrl) return { kind: 'url', value: replyUrl, source: 'reply' };
184
+
185
+ return { kind: null, value: null, source: null };
186
+ }
187
+
120
188
  /**
121
189
  * Registers the /start and /stop command handlers with the bot
122
190
  * @param {Object} bot - The Telegraf bot instance
@@ -129,9 +197,13 @@ export function extractStopSessionId(text, repliedTo) {
129
197
  * @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
130
198
  * @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
131
199
  * @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
200
+ * @param {Function} [options.getSolveQueue] - Returns the in-memory SolveQueue (for `/stop <url>`).
201
+ * When omitted, the URL flow degrades gracefully to a "no queue available"
202
+ * message so unit tests for non-URL paths don't need to construct a queue.
203
+ * See https://github.com/link-assistant/hive-mind/issues/1780.
132
204
  */
133
205
  export function registerStartStopCommands(bot, options) {
134
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
206
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
135
207
  const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
136
208
 
137
209
  /**
@@ -180,12 +252,146 @@ export function registerStartStopCommands(bot, options) {
180
252
  return { valid: true, chatId };
181
253
  }
182
254
 
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
255
+ /**
256
+ * Owner-only auth check for the /stop UUID and /stop URL flows. Mirrors the
257
+ * /log auth model: in private DMs the user is implicitly the owner; in
258
+ * groups they must be the chat creator. Replies with the appropriate error
259
+ * directly when auth fails.
260
+ *
261
+ * @param {Object} ctx - Telegraf context
262
+ * @param {string} label - Short human-readable label for the variant ('UUID', 'URL')
263
+ * @returns {Promise<boolean>} true when authorized
264
+ */
265
+ async function authorizeTargetedStop(ctx, label) {
266
+ const message = ctx.message;
267
+ const chatId = ctx.chat?.id;
268
+ const chatType = ctx.chat?.type;
269
+ if (chatType === 'private') return true;
270
+ if (!isGroupChat(ctx)) {
271
+ await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
272
+ return false;
273
+ }
274
+ if (!isChatAuthorized(chatId)) {
275
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
276
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
277
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
278
+ return false;
279
+ }
280
+ }
281
+ try {
282
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
283
+ if (!member || member.status !== 'creator') {
284
+ VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner`);
285
+ await ctx.reply(`❌ /stop <${label}> is only available to the chat owner.`, { reply_to_message_id: message.message_id });
286
+ return false;
287
+ }
288
+ } catch (error) {
289
+ console.error(`[ERROR] /stop <${label}>: getChatMember failed:`, error);
290
+ await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
291
+ return false;
292
+ }
293
+ return true;
294
+ }
295
+
296
+ /**
297
+ * Forward CTRL+C to a running isolated session via `$ --stop <uuid>`.
298
+ * Posts an ack reply, edits it with the result. Used by both the
299
+ * `/stop <UUID>` path (issue #524) and the `/stop <url>` path when the
300
+ * matched queue item is already executing in an isolated session
301
+ * (issue #1780).
302
+ *
303
+ * @param {Object} ctx - Telegraf context
304
+ * @param {string} sessionId - UUID of the session to stop
305
+ */
306
+ async function runStopIsolatedSessionFlow(ctx, sessionId) {
307
+ const message = ctx.message;
308
+ const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
309
+ parse_mode: 'Markdown',
310
+ reply_to_message_id: message.message_id,
311
+ });
312
+
313
+ let result;
314
+ try {
315
+ result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
316
+ } catch (error) {
317
+ console.error('[ERROR] /stop: stopIsolatedSession threw:', error);
318
+ result = { success: false, output: '', error: error?.message || String(error) };
319
+ }
320
+
321
+ const trimmedOutput = (result.output || '').toString().trim();
322
+ const trimmedError = (result.error || '').toString().trim();
323
+ const lines = [];
324
+ if (result.success) {
325
+ lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
326
+ lines.push('');
327
+ lines.push('The session should terminate shortly.');
328
+ if (trimmedOutput) {
329
+ lines.push('');
330
+ lines.push('```');
331
+ lines.push(trimmedOutput.slice(0, 1000));
332
+ lines.push('```');
333
+ }
334
+ } else {
335
+ lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
336
+ if (trimmedError) {
337
+ lines.push('');
338
+ lines.push('```');
339
+ lines.push(trimmedError.slice(0, 1000));
340
+ lines.push('```');
341
+ }
342
+ }
343
+
344
+ try {
345
+ await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
346
+ } catch (error) {
347
+ console.error('[ERROR] /stop: editMessageText failed, falling back to reply:', error);
348
+ await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Resolve a `/stop <url>` request against the in-memory solve queue.
354
+ * Returns an action descriptor that the dispatcher executes.
355
+ *
356
+ * @param {string} url - Normalized GitHub issue or PR URL
357
+ * @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
358
+ */
359
+ function resolveQueueLookupForUrl(url) {
360
+ if (typeof getSolveQueue !== 'function') {
361
+ return { action: 'no-queue' };
362
+ }
363
+ const queue = getSolveQueue({ verbose: VERBOSE });
364
+ const item = queue?.findByUrl?.(url);
365
+ if (!item) return { action: 'not-found' };
366
+
367
+ // Queued items have a defined .id and live in one of the per-tool queues.
368
+ // The cancel(id) call walks every per-tool queue and returns true on hit.
369
+ const cancelled = queue.cancel(item.id);
370
+ if (cancelled) {
371
+ return { action: 'cancel-queued', item, tool: item.tool || null };
372
+ }
373
+
374
+ // Not in a per-tool queue → must be in `processing`. If it was started
375
+ // via an isolation backend, item.sessionName is the start-command UUID
376
+ // and we can forward CTRL+C to it. Non-isolated runs have a screen name
377
+ // that is not UUID-shaped — we can't safely interrupt those from here.
378
+ const sessionId = item.sessionName && UUID_RE.test(item.sessionName) ? item.sessionName : null;
379
+ if (sessionId) {
380
+ return { action: 'stop-running', item, sessionId, tool: item.tool || null };
381
+ }
382
+ return { action: 'running-not-isolated', item, tool: item.tool || null };
383
+ }
384
+
385
+ // /stop command. Three modes (checked in this order, before any reply
386
+ // rejection so the queue-card-reply ergonomics from issue #1780 work):
387
+ // 1. `/stop <UUID>` or reply with UUID — forward CTRL+C via
388
+ // `$ --stop <UUID>` (issue #524).
389
+ // 2. `/stop <issue-or-pr-url>` or reply containing that URL — look up
390
+ // the matching solve queue item; cancel it if queued, forward
391
+ // CTRL+C if running with isolation (issue #1780).
392
+ // 3. bare `/stop` (optionally with a free-text reason) — pause new task
187
393
  // acceptance for the chat (issue #1081).
188
- // Only accessible by chat owner (creator) in both modes.
394
+ // Only accessible by chat owner (creator) in modes 1, 2 (in groups).
189
395
  bot.command('stop', async ctx => {
190
396
  VERBOSE && console.log('[VERBOSE] /stop command received');
191
397
  if (isOldMessage(ctx)) {
@@ -193,92 +399,76 @@ export function registerStartStopCommands(bot, options) {
193
399
  return;
194
400
  }
195
401
 
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).
402
+ // Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
403
+ // the chat-level stop, because both targeted modes are intentionally
404
+ // delivered as replies (issues #524, #1780).
199
405
  const message = ctx.message;
200
406
  const repliedTo = message?.reply_to_message || null;
201
- const { sessionId, source } = extractStopSessionId(message?.text || '', repliedTo);
407
+ const target = extractStopTarget(message?.text || '', repliedTo);
408
+
409
+ if (target.kind === 'uuid') {
410
+ const sessionId = target.value;
411
+ VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
412
+ const ok = await authorizeTargetedStop(ctx, 'UUID');
413
+ if (!ok) return;
414
+ await runStopIsolatedSessionFlow(ctx, sessionId);
415
+ return;
416
+ }
202
417
 
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
- }
418
+ if (target.kind === 'url') {
419
+ const url = target.value;
420
+ VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
421
+ const ok = await authorizeTargetedStop(ctx, 'URL');
422
+ if (!ok) return;
423
+
424
+ const lookup = resolveQueueLookupForUrl(url);
425
+ VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
426
+
427
+ if (lookup.action === 'no-queue') {
428
+ await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
429
+ parse_mode: 'Markdown',
430
+ reply_to_message_id: message.message_id,
431
+ });
432
+ return;
233
433
  }
234
434
 
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) };
435
+ if (lookup.action === 'not-found') {
436
+ await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
437
+ parse_mode: 'Markdown',
438
+ reply_to_message_id: message.message_id,
439
+ });
440
+ return;
246
441
  }
247
442
 
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
- }
443
+ if (lookup.action === 'cancel-queued') {
444
+ VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
445
+ const toolLabel = lookup.tool ? ` from \`${lookup.tool}\` queue` : '';
446
+ await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
447
+ parse_mode: 'Markdown',
448
+ reply_to_message_id: message.message_id,
449
+ });
450
+ return;
269
451
  }
270
452
 
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 });
453
+ if (lookup.action === 'stop-running') {
454
+ VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to running session ${lookup.sessionId} for ${url}`);
455
+ await runStopIsolatedSessionFlow(ctx, lookup.sessionId);
456
+ return;
276
457
  }
458
+
459
+ // running-not-isolated: a started, non-isolated screen session. We
460
+ // could shell out to `screen -X -S <name> stuff $'\003'`, but that's
461
+ // brittle and out of scope for #1780. Tell the user how to recover.
462
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
463
+ parse_mode: 'Markdown',
464
+ reply_to_message_id: message.message_id,
465
+ });
277
466
  return;
278
467
  }
279
468
 
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.
469
+ // No UUID or URL — fall through to the chat-level pause flow. That flow
470
+ // rejects forwards/replies on purpose (#1081) so a stray reply doesn't
471
+ // pause the chat.
282
472
  if (isForwardedOrReply(ctx)) {
283
473
  VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
284
474
  return;