@link-assistant/hive-mind 1.69.7 → 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,11 @@
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
+
3
9
  ## 1.69.7
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.7",
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",
@@ -185,6 +185,62 @@ export function extractStopTarget(text, repliedTo) {
185
185
  return { kind: null, value: null, source: null };
186
186
  }
187
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
+
188
244
  /**
189
245
  * Registers the /start and /stop command handlers with the bot
190
246
  * @param {Object} bot - The Telegraf bot instance
@@ -205,6 +261,18 @@ export function extractStopTarget(text, repliedTo) {
205
261
  export function registerStartStopCommands(bot, options) {
206
262
  const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
207
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
+ }
208
276
 
209
277
  /**
210
278
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
@@ -253,16 +321,26 @@ export function registerStartStopCommands(bot, options) {
253
321
  }
254
322
 
255
323
  /**
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.
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.
260
335
  *
261
336
  * @param {Object} ctx - Telegraf context
262
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
263
341
  * @returns {Promise<boolean>} true when authorized
264
342
  */
265
- async function authorizeTargetedStop(ctx, label) {
343
+ async function authorizeTargetedStop(ctx, label, { queueItem = null, sessionInfo = null } = {}) {
266
344
  const message = ctx.message;
267
345
  const chatId = ctx.chat?.id;
268
346
  const chatType = ctx.chat?.type;
@@ -278,11 +356,17 @@ export function registerStartStopCommands(bot, options) {
278
356
  return false;
279
357
  }
280
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
+
281
365
  try {
282
366
  const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
283
367
  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 });
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 });
286
370
  return false;
287
371
  }
288
372
  } catch (error) {
@@ -350,19 +434,35 @@ export function registerStartStopCommands(bot, options) {
350
434
  }
351
435
 
352
436
  /**
353
- * Resolve a `/stop <url>` request against the in-memory solve queue.
354
- * Returns an action descriptor that the dispatcher executes.
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).
355
441
  *
356
442
  * @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 }}
443
+ * @returns {{ action: 'no-queue'|'not-found'|'candidate', item?: Object, queue?: Object }}
358
444
  */
359
- function resolveQueueLookupForUrl(url) {
445
+ function findQueueCandidateForUrl(url) {
360
446
  if (typeof getSolveQueue !== 'function') {
361
447
  return { action: 'no-queue' };
362
448
  }
363
449
  const queue = getSolveQueue({ verbose: VERBOSE });
364
450
  const item = queue?.findByUrl?.(url);
365
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;
366
466
 
367
467
  // Queued items have a defined .id and live in one of the per-tool queues.
368
468
  // The cancel(id) call walks every per-tool queue and returns true on hit.
@@ -409,7 +509,17 @@ export function registerStartStopCommands(bot, options) {
409
509
  if (target.kind === 'uuid') {
410
510
  const sessionId = target.value;
411
511
  VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
412
- const ok = await authorizeTargetedStop(ctx, 'UUID');
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);
521
+ }
522
+ const ok = await authorizeTargetedStop(ctx, 'UUID', { sessionInfo });
413
523
  if (!ok) return;
414
524
  await runStopIsolatedSessionFlow(ctx, sessionId);
415
525
  return;
@@ -418,7 +528,13 @@ export function registerStartStopCommands(bot, options) {
418
528
  if (target.kind === 'url') {
419
529
  const url = target.value;
420
530
  VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
421
- const ok = await authorizeTargetedStop(ctx, 'URL');
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 });
422
538
  if (!ok) return;
423
539
 
424
540
  const lookup = resolveQueueLookupForUrl(url);
@@ -443,6 +559,14 @@ export function registerStartStopCommands(bot, options) {
443
559
  if (lookup.action === 'cancel-queued') {
444
560
  VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
445
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
+
446
570
  await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
447
571
  parse_mode: 'Markdown',
448
572
  reply_to_message_id: message.message_id,