@link-assistant/hive-mind 1.69.6 → 1.69.8

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.69.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 175eaee: Fix two defects in the Telegram `/stop` command. (1) When `/stop` cancels a queued task by URL or reply, the original "⏳ Waiting (… queue #N)" card is now edited in place to show the task was cancelled (instead of leaving it stale). (2) Allow the user who originally ran `/solve` or `/hive` to `/stop` their own task by UUID or URL in a group chat, mirroring the requester authorization already used by `/terminal_watch` and `/watch` (PR #1779). The chat-creator fallback is preserved, so chat owners can still stop any task.
8
+
9
+ ## 1.69.7
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
3
15
  ## 1.69.6
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.69.6",
3
+ "version": "1.69.8",
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,122 @@ 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
+
188
+ /**
189
+ * Update the queue card message in Telegram to show that the task was
190
+ * cancelled via /stop. Best-effort: silently swallows edit failures (e.g.,
191
+ * the card was already cleared by the consumer loop) — the dispatcher still
192
+ * sends its own ack reply, so the user always gets feedback.
193
+ *
194
+ * @param {Object} item - SolveQueueItem; reads .messageInfo and .ctx
195
+ * @param {string} url - GitHub issue/PR URL of the cancelled task
196
+ * @param {string|null} tool - Per-tool queue name (claude/agent/codex/...)
197
+ * @param {string} stopperName - Display name of the user who ran /stop
198
+ * @returns {Promise<boolean>} true when the card was edited
199
+ * @see https://github.com/link-assistant/hive-mind/issues/1783
200
+ */
201
+ export async function updateQueueCardForCancellation(item, url, tool, stopperName) {
202
+ if (!item || !item.messageInfo || !item.ctx) return false;
203
+ const toolSuffix = tool ? ` from \`${tool}\` queue` : '';
204
+ const stopperSuffix = stopperName ? ` by ${stopperName}` : '';
205
+ const text = `🗑 *Cancelled*\n\n${url}\n\nRemoved${toolSuffix}${stopperSuffix} via /stop.`;
206
+ try {
207
+ const { chatId, messageId } = item.messageInfo;
208
+ await item.ctx.telegram.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
209
+ // Match the consumer's contract: once a card reaches a terminal state we
210
+ // forget the message coordinates so nothing else tries to edit it.
211
+ item.messageInfo = null;
212
+ return true;
213
+ } catch (error) {
214
+ console.error('[ERROR] /stop: failed to update queue card for cancellation:', error?.message || error);
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Returns true when the user issuing /stop is the same user who originally
221
+ * requested the targeted task. Mirrors `isTerminalWatchSessionRequester` from
222
+ * /terminal_watch (PR #1779) so the two commands behave consistently.
223
+ *
224
+ * Accepts the requester either from a `SolveQueueItem` (URL flow) or from
225
+ * a tracked session info record (UUID flow).
226
+ *
227
+ * @param {Object} args
228
+ * @param {number|string|null|undefined} args.userId - ctx.from.id of the /stop sender
229
+ * @param {Object|null} [args.queueItem] - matched SolveQueueItem, if any
230
+ * @param {Object|null} [args.sessionInfo] - tracked session info, if any
231
+ * @returns {boolean}
232
+ * @see https://github.com/link-assistant/hive-mind/issues/1783
233
+ */
234
+ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo = null } = {}) {
235
+ if (userId === null || userId === undefined) return false;
236
+ const candidates = [queueItem?.requesterUserId, sessionInfo?.requesterUserId];
237
+ for (const candidate of candidates) {
238
+ if (candidate === null || candidate === undefined) continue;
239
+ if (String(candidate) === String(userId)) return true;
240
+ }
241
+ return false;
242
+ }
243
+
120
244
  /**
121
245
  * Registers the /start and /stop command handlers with the bot
122
246
  * @param {Object} bot - The Telegraf bot instance
@@ -129,10 +253,26 @@ export function extractStopSessionId(text, repliedTo) {
129
253
  * @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
130
254
  * @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
131
255
  * @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
256
+ * @param {Function} [options.getSolveQueue] - Returns the in-memory SolveQueue (for `/stop <url>`).
257
+ * When omitted, the URL flow degrades gracefully to a "no queue available"
258
+ * message so unit tests for non-URL paths don't need to construct a queue.
259
+ * See https://github.com/link-assistant/hive-mind/issues/1780.
132
260
  */
133
261
  export function registerStartStopCommands(bot, options) {
134
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
262
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
135
263
  const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
264
+ // Issue #1783: look the UUID up in the session monitor so /stop can let the
265
+ // user who started the task stop it (mirrors /terminal_watch from PR #1779).
266
+ // Test stubs can inject getTrackedSessionInfo directly via options.
267
+ // The real session-monitor.getTrackedSessionInfo is sync; we wrap it in an
268
+ // async tolerant helper so tests can pass either a sync or async stub.
269
+ async function lookupTrackedSessionInfo(sessionId) {
270
+ if (typeof options.getTrackedSessionInfo === 'function') {
271
+ return await options.getTrackedSessionInfo(sessionId);
272
+ }
273
+ const mod = await import('./session-monitor.lib.mjs');
274
+ return mod.getTrackedSessionInfo(sessionId);
275
+ }
136
276
 
137
277
  /**
138
278
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
@@ -180,12 +320,178 @@ export function registerStartStopCommands(bot, options) {
180
320
  return { valid: true, chatId };
181
321
  }
182
322
 
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
323
+ /**
324
+ * Authorization check for the /stop UUID and /stop URL flows.
325
+ *
326
+ * In private DMs the user is implicitly authorized. In group chats the user
327
+ * is authorized when EITHER:
328
+ * - They are the chat creator, OR
329
+ * - They are the original requester of the task being stopped (the user
330
+ * who ran the /solve or /hive that produced the queue item / session).
331
+ *
332
+ * This mirrors /terminal_watch and /watch (PR #1779) which already let the
333
+ * task requester act on their own session without requiring chat-owner
334
+ * privileges. See https://github.com/link-assistant/hive-mind/issues/1783.
335
+ *
336
+ * @param {Object} ctx - Telegraf context
337
+ * @param {string} label - Short human-readable label for the variant ('UUID', 'URL')
338
+ * @param {Object} [opts]
339
+ * @param {Object|null} [opts.queueItem] - matched SolveQueueItem, if known
340
+ * @param {Object|null} [opts.sessionInfo] - tracked session info, if known
341
+ * @returns {Promise<boolean>} true when authorized
342
+ */
343
+ async function authorizeTargetedStop(ctx, label, { queueItem = null, sessionInfo = null } = {}) {
344
+ const message = ctx.message;
345
+ const chatId = ctx.chat?.id;
346
+ const chatType = ctx.chat?.type;
347
+ if (chatType === 'private') return true;
348
+ if (!isGroupChat(ctx)) {
349
+ await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
350
+ return false;
351
+ }
352
+ if (!isChatAuthorized(chatId)) {
353
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
354
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
355
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
356
+ return false;
357
+ }
358
+ }
359
+
360
+ if (isStopTargetRequester({ userId: ctx.from?.id, queueItem, sessionInfo })) {
361
+ VERBOSE && console.log(`[VERBOSE] /stop <${label}> allowed for task requester ${ctx.from?.id}`);
362
+ return true;
363
+ }
364
+
365
+ try {
366
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
367
+ if (!member || member.status !== 'creator') {
368
+ VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner or task requester`);
369
+ await ctx.reply(`❌ /stop <${label}> is only available to the chat owner or the user who started this task.`, { reply_to_message_id: message.message_id });
370
+ return false;
371
+ }
372
+ } catch (error) {
373
+ console.error(`[ERROR] /stop <${label}>: getChatMember failed:`, error);
374
+ await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
375
+ return false;
376
+ }
377
+ return true;
378
+ }
379
+
380
+ /**
381
+ * Forward CTRL+C to a running isolated session via `$ --stop <uuid>`.
382
+ * Posts an ack reply, edits it with the result. Used by both the
383
+ * `/stop <UUID>` path (issue #524) and the `/stop <url>` path when the
384
+ * matched queue item is already executing in an isolated session
385
+ * (issue #1780).
386
+ *
387
+ * @param {Object} ctx - Telegraf context
388
+ * @param {string} sessionId - UUID of the session to stop
389
+ */
390
+ async function runStopIsolatedSessionFlow(ctx, sessionId) {
391
+ const message = ctx.message;
392
+ const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
393
+ parse_mode: 'Markdown',
394
+ reply_to_message_id: message.message_id,
395
+ });
396
+
397
+ let result;
398
+ try {
399
+ result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
400
+ } catch (error) {
401
+ console.error('[ERROR] /stop: stopIsolatedSession threw:', error);
402
+ result = { success: false, output: '', error: error?.message || String(error) };
403
+ }
404
+
405
+ const trimmedOutput = (result.output || '').toString().trim();
406
+ const trimmedError = (result.error || '').toString().trim();
407
+ const lines = [];
408
+ if (result.success) {
409
+ lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
410
+ lines.push('');
411
+ lines.push('The session should terminate shortly.');
412
+ if (trimmedOutput) {
413
+ lines.push('');
414
+ lines.push('```');
415
+ lines.push(trimmedOutput.slice(0, 1000));
416
+ lines.push('```');
417
+ }
418
+ } else {
419
+ lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
420
+ if (trimmedError) {
421
+ lines.push('');
422
+ lines.push('```');
423
+ lines.push(trimmedError.slice(0, 1000));
424
+ lines.push('```');
425
+ }
426
+ }
427
+
428
+ try {
429
+ await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
430
+ } catch (error) {
431
+ console.error('[ERROR] /stop: editMessageText failed, falling back to reply:', error);
432
+ await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Find the candidate queue/processing item for a `/stop <url>` request
438
+ * without mutating queue state. Used by the dispatcher to look up the
439
+ * task's requesterUserId for authorization before any cancellation
440
+ * happens (#1783).
441
+ *
442
+ * @param {string} url - Normalized GitHub issue or PR URL
443
+ * @returns {{ action: 'no-queue'|'not-found'|'candidate', item?: Object, queue?: Object }}
444
+ */
445
+ function findQueueCandidateForUrl(url) {
446
+ if (typeof getSolveQueue !== 'function') {
447
+ return { action: 'no-queue' };
448
+ }
449
+ const queue = getSolveQueue({ verbose: VERBOSE });
450
+ const item = queue?.findByUrl?.(url);
451
+ if (!item) return { action: 'not-found' };
452
+ return { action: 'candidate', item, queue };
453
+ }
454
+
455
+ /**
456
+ * Resolve a `/stop <url>` request against the in-memory solve queue.
457
+ * Returns an action descriptor that the dispatcher executes.
458
+ *
459
+ * @param {string} url - Normalized GitHub issue or PR URL
460
+ * @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
461
+ */
462
+ function resolveQueueLookupForUrl(url) {
463
+ const candidate = findQueueCandidateForUrl(url);
464
+ if (candidate.action !== 'candidate') return candidate;
465
+ const { item, queue } = candidate;
466
+
467
+ // Queued items have a defined .id and live in one of the per-tool queues.
468
+ // The cancel(id) call walks every per-tool queue and returns true on hit.
469
+ const cancelled = queue.cancel(item.id);
470
+ if (cancelled) {
471
+ return { action: 'cancel-queued', item, tool: item.tool || null };
472
+ }
473
+
474
+ // Not in a per-tool queue → must be in `processing`. If it was started
475
+ // via an isolation backend, item.sessionName is the start-command UUID
476
+ // and we can forward CTRL+C to it. Non-isolated runs have a screen name
477
+ // that is not UUID-shaped — we can't safely interrupt those from here.
478
+ const sessionId = item.sessionName && UUID_RE.test(item.sessionName) ? item.sessionName : null;
479
+ if (sessionId) {
480
+ return { action: 'stop-running', item, sessionId, tool: item.tool || null };
481
+ }
482
+ return { action: 'running-not-isolated', item, tool: item.tool || null };
483
+ }
484
+
485
+ // /stop command. Three modes (checked in this order, before any reply
486
+ // rejection so the queue-card-reply ergonomics from issue #1780 work):
487
+ // 1. `/stop <UUID>` or reply with UUID — forward CTRL+C via
488
+ // `$ --stop <UUID>` (issue #524).
489
+ // 2. `/stop <issue-or-pr-url>` or reply containing that URL — look up
490
+ // the matching solve queue item; cancel it if queued, forward
491
+ // CTRL+C if running with isolation (issue #1780).
492
+ // 3. bare `/stop` (optionally with a free-text reason) — pause new task
187
493
  // acceptance for the chat (issue #1081).
188
- // Only accessible by chat owner (creator) in both modes.
494
+ // Only accessible by chat owner (creator) in modes 1, 2 (in groups).
189
495
  bot.command('stop', async ctx => {
190
496
  VERBOSE && console.log('[VERBOSE] /stop command received');
191
497
  if (isOldMessage(ctx)) {
@@ -193,92 +499,100 @@ export function registerStartStopCommands(bot, options) {
193
499
  return;
194
500
  }
195
501
 
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).
502
+ // Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
503
+ // the chat-level stop, because both targeted modes are intentionally
504
+ // delivered as replies (issues #524, #1780).
199
505
  const message = ctx.message;
200
506
  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
- }
507
+ const target = extractStopTarget(message?.text || '', repliedTo);
508
+
509
+ if (target.kind === 'uuid') {
510
+ const sessionId = target.value;
511
+ VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
512
+ // Look up the session's owner before auth so the original task requester
513
+ // can /stop their own session in a group even if they aren't the chat
514
+ // creator (#1783). Lookup failures are non-fatal: we fall through to the
515
+ // chat-owner-only check.
516
+ let sessionInfo = null;
517
+ try {
518
+ sessionInfo = await lookupTrackedSessionInfo(sessionId);
519
+ } catch (error) {
520
+ console.error('[ERROR] /stop: getTrackedSessionInfo failed:', error);
233
521
  }
522
+ const ok = await authorizeTargetedStop(ctx, 'UUID', { sessionInfo });
523
+ if (!ok) return;
524
+ await runStopIsolatedSessionFlow(ctx, sessionId);
525
+ return;
526
+ }
234
527
 
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
- });
528
+ if (target.kind === 'url') {
529
+ const url = target.value;
530
+ VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
531
+
532
+ // Look up the queue item BEFORE auth so we can allow the original task
533
+ // requester to cancel their own task in a group (#1783). The lookup
534
+ // here does NOT mutate the queue — actual cancel happens below in
535
+ // resolveQueueLookupForUrl after auth has passed.
536
+ const candidate = findQueueCandidateForUrl(url);
537
+ const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null });
538
+ if (!ok) return;
539
+
540
+ const lookup = resolveQueueLookupForUrl(url);
541
+ VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
542
+
543
+ if (lookup.action === 'no-queue') {
544
+ 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.`, {
545
+ parse_mode: 'Markdown',
546
+ reply_to_message_id: message.message_id,
547
+ });
548
+ return;
549
+ }
239
550
 
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) };
551
+ if (lookup.action === 'not-found') {
552
+ 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).`, {
553
+ parse_mode: 'Markdown',
554
+ reply_to_message_id: message.message_id,
555
+ });
556
+ return;
246
557
  }
247
558
 
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
- }
559
+ if (lookup.action === 'cancel-queued') {
560
+ VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
561
+ const toolLabel = lookup.tool ? ` from \`${lookup.tool}\` queue` : '';
562
+
563
+ // Update the original queue card ("⏳ Waiting (claude queue #3)") to
564
+ // reflect that the task was cancelled. The queue stores the message
565
+ // coordinates on the item itself when the card was first posted —
566
+ // see telegram-bot.mjs where `item.messageInfo` is wired up.
567
+ const stopperName = ctx.from?.username ? `@${ctx.from.username}` : ctx.from?.first_name || `user ${ctx.from?.id}`;
568
+ await updateQueueCardForCancellation(lookup.item, url, lookup.tool, stopperName);
569
+
570
+ await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
571
+ parse_mode: 'Markdown',
572
+ reply_to_message_id: message.message_id,
573
+ });
574
+ return;
269
575
  }
270
576
 
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 });
577
+ if (lookup.action === 'stop-running') {
578
+ VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to running session ${lookup.sessionId} for ${url}`);
579
+ await runStopIsolatedSessionFlow(ctx, lookup.sessionId);
580
+ return;
276
581
  }
582
+
583
+ // running-not-isolated: a started, non-isolated screen session. We
584
+ // could shell out to `screen -X -S <name> stuff $'\003'`, but that's
585
+ // brittle and out of scope for #1780. Tell the user how to recover.
586
+ 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\`.`, {
587
+ parse_mode: 'Markdown',
588
+ reply_to_message_id: message.message_id,
589
+ });
277
590
  return;
278
591
  }
279
592
 
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.
593
+ // No UUID or URL — fall through to the chat-level pause flow. That flow
594
+ // rejects forwards/replies on purpose (#1081) so a stray reply doesn't
595
+ // pause the chat.
282
596
  if (isForwardedOrReply(ctx)) {
283
597
  VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
284
598
  return;