@link-assistant/hive-mind 1.56.17 → 1.56.18

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.56.18
4
+
5
+ ### Patch Changes
6
+
7
+ - 47810ae: Telegram bot: add experimental `/subscribe` + `/unsubscribe` commands so users can opt in to receive a private DM forward of the `/solve` work-session completion message (commands work in both private and group chats; subscriptions are kept in memory and reset on bot restart). The completion message now includes both an `Issue:` line (the original URL passed to `/solve`) and, when the agent created a pull request for that issue, a follow-up `Pull request:` line so reviewers see both links without leaving the chat. (#1688)
8
+
3
9
  ## 1.56.17
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.56.17",
3
+ "version": "1.56.18",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "hive-telegram-bot": "./src/telegram-bot.mjs"
16
16
  },
17
17
  "scripts": {
18
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
18
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1688-subscribe-and-pr-link.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
19
19
  "test:queue": "node tests/solve-queue.test.mjs",
20
20
  "test:limits-display": "node tests/limits-display.test.mjs",
21
21
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -18,6 +18,7 @@
18
18
  import { promisify } from 'util';
19
19
  import { exec as execCallback } from 'child_process';
20
20
  import { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
21
+ import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
21
22
 
22
23
  export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
23
24
 
@@ -251,6 +252,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
251
252
 
252
253
  try {
253
254
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
255
+
256
+ // Issue #1688: When the original /solve URL was an issue, look up the
257
+ // linked PR so the completion message can include both an `Issue:` and
258
+ // a `Pull request:` line. Failures are logged and ignored — the
259
+ // notification still goes out without the PR line.
260
+ let pullRequestUrl = null;
261
+ try {
262
+ pullRequestUrl = await resolvePullRequestUrlForSession(sessionInfo, { verbose, lookupLinkedPullRequest: options.lookupLinkedPullRequest });
263
+ } catch (lookupError) {
264
+ if (verbose) {
265
+ console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
266
+ }
267
+ }
268
+
254
269
  const message = formatSessionCompletionMessage({
255
270
  sessionName,
256
271
  sessionInfo,
@@ -258,13 +273,44 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
258
273
  observedEndTime: new Date(),
259
274
  exitCode: finalExitCode,
260
275
  infoBlock: sessionInfo?.infoBlock || '',
276
+ pullRequestUrl,
261
277
  });
262
278
 
263
279
  // Update the original reply message if messageId is available, otherwise send new message
280
+ let notifyFromChatId = null;
281
+ let notifyMessageId = null;
264
282
  if (sessionInfo.messageId) {
265
283
  await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
284
+ notifyFromChatId = sessionInfo.chatId;
285
+ notifyMessageId = sessionInfo.messageId;
266
286
  } else {
267
- await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
287
+ const sent = await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
288
+ notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
289
+ notifyMessageId = sent?.message_id || null;
290
+ }
291
+
292
+ // Issue #1688: forward the same completion message to every /subscribe-d user
293
+ // in their private chat with the bot. Failures are logged but don't block
294
+ // completion of the parent session.
295
+ if (getSubscriberCount() > 0 && notifyFromChatId && notifyMessageId) {
296
+ try {
297
+ const skipUserIds = new Set();
298
+ if (sessionInfo?.requesterUserId) skipUserIds.add(sessionInfo.requesterUserId);
299
+ const summary = await notifySubscribers({
300
+ bot,
301
+ fromChatId: notifyFromChatId,
302
+ messageId: notifyMessageId,
303
+ fallbackText: message,
304
+ fallbackOptions: { parse_mode: 'Markdown' },
305
+ skipUserIds,
306
+ verbose,
307
+ });
308
+ if (verbose) {
309
+ console.log(`[VERBOSE] Subscribe notify summary for ${sessionName}: forwarded=${summary.forwarded}, sent=${summary.sent}, skipped=${summary.skipped}, failures=${summary.failures.length}`);
310
+ }
311
+ } catch (notifyError) {
312
+ console.error(`[session-monitor] notifySubscribers failed for ${sessionName}:`, notifyError);
313
+ }
268
314
  }
269
315
 
270
316
  completeSession(sessionName, finalExitCode || 0, verbose);
@@ -285,6 +331,51 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
285
331
  }
286
332
  }
287
333
 
334
+ /**
335
+ * Look up the URL of a pull request linked to the issue this session worked on.
336
+ * Returns null when the session was already operating on a PR, the URL context
337
+ * is missing, or no linked PR exists.
338
+ *
339
+ * Lazy-loads the GitHub batch helper so unrelated tests/imports don't pull
340
+ * GitHub deps. Tests can override the lookup via `options.lookupLinkedPullRequest`.
341
+ *
342
+ * @param {Object} sessionInfo
343
+ * @param {Object} [options]
344
+ * @param {boolean} [options.verbose]
345
+ * @param {Function} [options.lookupLinkedPullRequest] - Optional override `(ctx) => Promise<string|null>`
346
+ * @returns {Promise<string|null>} PR URL or null
347
+ *
348
+ * @see https://github.com/link-assistant/hive-mind/issues/1688
349
+ */
350
+ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, lookupLinkedPullRequest = null } = {}) {
351
+ const ctx = sessionInfo?.urlContext;
352
+ if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
353
+ return null;
354
+ }
355
+
356
+ if (typeof lookupLinkedPullRequest === 'function') {
357
+ return await lookupLinkedPullRequest(ctx);
358
+ }
359
+
360
+ try {
361
+ const { batchCheckPullRequestsForIssues } = await import('./github.lib.mjs');
362
+ const result = await batchCheckPullRequestsForIssues(ctx.owner, ctx.repo, [ctx.number]);
363
+ const linkedPRs = result?.[ctx.number]?.linkedPRs || [];
364
+ if (linkedPRs.length > 0 && linkedPRs[0].url) {
365
+ if (verbose) {
366
+ console.log(`[VERBOSE] Found linked PR ${linkedPRs[0].url} for issue ${ctx.owner}/${ctx.repo}#${ctx.number}`);
367
+ }
368
+ return linkedPRs[0].url;
369
+ }
370
+ } catch (error) {
371
+ if (verbose) {
372
+ console.log(`[VERBOSE] batchCheckPullRequestsForIssues failed for ${ctx.owner}/${ctx.repo}#${ctx.number}: ${error?.message || error}`);
373
+ }
374
+ throw error;
375
+ }
376
+ return null;
377
+ }
378
+
288
379
  /**
289
380
  * Start the session monitoring interval
290
381
  * @param {Object} bot - Telegraf bot instance for sending messages
@@ -551,7 +551,7 @@ async function safeReply(ctx, text, options = {}) {
551
551
  }
552
552
  }
553
553
 
554
- async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
554
+ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null) {
555
555
  const { chat, message_id: msgId } = startingMessage;
556
556
  const safeEdit = async text => {
557
557
  try {
@@ -560,22 +560,20 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
560
560
  console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
561
561
  }
562
562
  };
563
+ const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
563
564
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
564
565
  let result, session;
565
566
  if (iso) {
566
567
  session = iso.runner.generateSessionId();
567
568
  VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
568
569
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
569
- if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock }, VERBOSE);
570
+ if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
570
571
  } else {
571
572
  result = await executeStartScreen(commandName, args);
572
573
  const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
573
574
  session = match ? match[1] : 'unknown';
574
- // Issue #1586: Track non-isolation sessions with timeout-based expiry.
575
- // These sessions cannot reliably detect completion (screen stays alive via
576
- // `exec bash`), so active URL checks auto-expire them after 10 min.
577
- // This prevents accidental duplicate commands within the timeout window.
578
- if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock }, VERBOSE);
575
+ // Issue #1586: Non-isolation sessions auto-expire after 10 min — screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
576
+ if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
579
577
  }
580
578
  if (result.warning) return safeEdit(`āš ļø ${result.warning}`);
581
579
  if (result.success) {
@@ -662,13 +660,14 @@ bot.command('help', async ctx => {
662
660
  message += '*/merge* - Merge queue (experimental)\n';
663
661
  message += 'Usage: `/merge <github-repo-url>`\n';
664
662
  message += "Merges all PRs with 'ready' label sequentially.\n";
663
+ message += '*/subscribe* / */unsubscribe* - šŸ”” Get private DM forward of /solve completion (experimental, #1688)\n';
665
664
  message += '*/help* - Show this help message\n';
666
665
  message += '*/stop* - Stop accepting new tasks (owner only)\n';
667
666
  message += '*/start* - Resume accepting tasks (owner only)\n\n';
668
- message += 'šŸ”” *Session Notifications:* The bot monitors sessions and notifies when they complete.\n';
667
+ message += 'šŸ”” *Session Notifications:* The bot monitors sessions and notifies when they complete. Use /subscribe to also get DM forwards (in-memory, resets on restart).\n';
669
668
  if (ISOLATION_BACKEND) message += `šŸ”’ *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
670
669
  message += '\n';
671
- message += 'āš ļø *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats.\n\n';
670
+ message += 'āš ļø *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /subscribe and /unsubscribe work in private and group chats.\n\n';
672
671
  message += 'šŸ”§ *Common Options:*\n';
673
672
  message += `• \`--model <model>\` or \`-m\` - ${buildModelOptionDescription()}\n`;
674
673
  message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
@@ -772,6 +771,8 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
772
771
  registerMergeCommand(bot, sharedCommandOpts);
773
772
  const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
774
773
  const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
774
+ const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
775
+ registerSubscribeCommands(bot, sharedCommandOpts);
775
776
 
776
777
  // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
777
778
  async function handleSolveCommand(ctx) {
@@ -983,10 +984,10 @@ async function handleSolveCommand(ctx) {
983
984
  const normalizedUrl = validation.parsed.normalized;
984
985
 
985
986
  const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
986
- // Issue #1228: Show only user-provided options (exclude locked overrides to avoid duplication)
987
- // Issue #1460: Escape options text to prevent Markdown parsing errors
987
+ // #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
988
988
  const userOptionsRaw = userArgs.slice(1).join(' ');
989
- let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(normalizedUrl)}`;
989
+ const urlLabel = validation.parsed?.type === 'pull' ? 'Pull request' : 'Issue';
990
+ let infoBlock = `Requested by: ${requester}\n${urlLabel}: ${escapeMarkdown(normalizedUrl)}`;
990
991
  if (userOptionsRaw) infoBlock += `\n\nšŸ›  Options: ${escapeMarkdown(userOptionsRaw)}`;
991
992
  if (solveOverrides.length > 0) infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}šŸ”’ Locked options: ${escapeMarkdown(solveOverrides.join(' '))}`;
992
993
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
@@ -1012,12 +1013,15 @@ async function handleSolveCommand(ctx) {
1012
1013
  return;
1013
1014
  }
1014
1015
 
1016
+ // Issue #1688: parsed URL context lets the completion message look up linked PRs.
1017
+ const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: validation.parsed.normalized || normalizedUrl } : null;
1018
+
1015
1019
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
1016
1020
  if (check.canStart && toolQueuedCount === 0) {
1017
1021
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
1018
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
1022
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext);
1019
1023
  } else {
1020
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
1024
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext });
1021
1025
  let queueMessage = `šŸ“‹ Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
1022
1026
  if (check.reason) queueMessage += `\n\nā³ Waiting: ${escapeMarkdown(check.reason)}`;
1023
1027
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
@@ -1252,7 +1256,7 @@ bot.on('message', async (ctx, next) => {
1252
1256
  }
1253
1257
  }
1254
1258
 
1255
- // Check if this is a command we handle
1259
+ // /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback — Telegraf's bot.command() is sufficient.
1256
1260
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1257
1261
  const handlers = { ...solveHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand };
1258
1262
 
@@ -80,7 +80,27 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
80
80
  if (iso) {
81
81
  const sid = iso.runner.generateSessionId();
82
82
  const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
83
- if (r.success) trackSession(sid, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: item.command || 'solve', isolationBackend: iso.backend, sessionId: sid, tool: item.tool || 'claude', infoBlock: item.infoBlock }, verbose);
83
+ if (r.success)
84
+ trackSession(
85
+ sid,
86
+ {
87
+ chatId: item.ctx?.chat?.id,
88
+ messageId: item.messageInfo?.messageId,
89
+ startTime: new Date(),
90
+ url: item.url,
91
+ command: item.command || 'solve',
92
+ isolationBackend: iso.backend,
93
+ sessionId: sid,
94
+ tool: item.tool || 'claude',
95
+ infoBlock: item.infoBlock,
96
+ // Issue #1688: propagate URL context + requester through the queue so the
97
+ // completion notification can append a 'Pull request:' line and skip
98
+ // notifying the requester twice via /subscribe.
99
+ urlContext: item.urlContext || null,
100
+ requesterUserId: item.requesterUserId ?? null,
101
+ },
102
+ verbose
103
+ );
84
104
  return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
85
105
  }
86
106
  return fallbackCallback(item);
@@ -43,6 +43,11 @@ class SolveQueueItem {
43
43
  this.requester = options.requester;
44
44
  this.infoBlock = options.infoBlock;
45
45
  this.tool = options.tool || 'claude';
46
+ // Issue #1688: keep parsed URL context (owner/repo/number/type) so completion
47
+ // notifications can look up linked PRs for issue URLs.
48
+ this.urlContext = options.urlContext || null;
49
+ // Issue #1688: requester user ID for /subscribe duplicate-suppression.
50
+ this.requesterUserId = options.ctx?.from?.id ?? null;
46
51
  this.createdAt = new Date();
47
52
  this.startedAt = null;
48
53
  this.status = QueueItemStatus.QUEUED;
@@ -1361,7 +1366,20 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1361
1366
  const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
1362
1367
  const session = match ? match[1] : null;
1363
1368
  if (session) {
1364
- trackSessionFn(session, { chatId: item.ctx?.chat?.id, messageId: item.messageInfo?.messageId, startTime: new Date(), url: item.url, command: 'solve', tool: item.tool || 'claude', infoBlock: item.infoBlock });
1369
+ trackSessionFn(session, {
1370
+ chatId: item.ctx?.chat?.id,
1371
+ messageId: item.messageInfo?.messageId,
1372
+ startTime: new Date(),
1373
+ url: item.url,
1374
+ command: 'solve',
1375
+ tool: item.tool || 'claude',
1376
+ infoBlock: item.infoBlock,
1377
+ // Issue #1688: propagate URL context + requester so the completion
1378
+ // notification can append a 'Pull request:' line and skip
1379
+ // notifying the requester twice via /subscribe.
1380
+ urlContext: item.urlContext || null,
1381
+ requesterUserId: item.requesterUserId ?? null,
1382
+ });
1365
1383
  }
1366
1384
  }
1367
1385
  return result;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Telegram /subscribe and /unsubscribe command implementation (experimental).
3
+ *
4
+ * In-memory store of users who want to receive a private notification when
5
+ * a /solve (or alias) work session completes. Storage is intentionally
6
+ * volatile: subscriptions are cleared on bot restart since we do not yet
7
+ * have a database (issue #1688).
8
+ *
9
+ * /subscribe and /unsubscribe work in both private chats and public group
10
+ * chats. They store the Telegram user ID (not the chat ID) so a single
11
+ * user only ever receives one private notification, regardless of which
12
+ * chat they ran the command in.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/1688
15
+ */
16
+
17
+ // Map<userId, { username, firstName, subscribedAt, sourceChatId }>
18
+ const subscribers = new Map();
19
+
20
+ export function isSubscribed(userId) {
21
+ if (userId === null || userId === undefined) return false;
22
+ return subscribers.has(userId);
23
+ }
24
+
25
+ export function addSubscriber(userId, info = {}) {
26
+ if (userId === null || userId === undefined) return false;
27
+ const existed = subscribers.has(userId);
28
+ subscribers.set(userId, {
29
+ username: info.username || null,
30
+ firstName: info.firstName || null,
31
+ subscribedAt: existed ? subscribers.get(userId).subscribedAt : new Date(),
32
+ sourceChatId: info.sourceChatId ?? null,
33
+ });
34
+ return !existed;
35
+ }
36
+
37
+ export function removeSubscriber(userId) {
38
+ if (userId === null || userId === undefined) return false;
39
+ return subscribers.delete(userId);
40
+ }
41
+
42
+ export function getSubscribers() {
43
+ return Array.from(subscribers.entries()).map(([userId, info]) => ({ userId, ...info }));
44
+ }
45
+
46
+ export function getSubscriberCount() {
47
+ return subscribers.size;
48
+ }
49
+
50
+ export function resetSubscribersForTests() {
51
+ subscribers.clear();
52
+ }
53
+
54
+ /**
55
+ * Forward (or send) a session-completion notification to every subscribed user
56
+ * in their private chat with the bot.
57
+ *
58
+ * Strategy:
59
+ * 1. Try forwardMessage(userId, sourceChatId, messageId) — preserves the
60
+ * original visual style of the reply Telegram users already see in chat.
61
+ * 2. If that fails (e.g. the user has never started a private chat with
62
+ * the bot, or the message can't be forwarded), fall back to
63
+ * sendMessage(userId, fallbackText) so the notification still arrives
64
+ * when possible.
65
+ *
66
+ * Returns a summary so callers can log delivery results.
67
+ *
68
+ * @param {Object} params
69
+ * @param {Object} params.bot - Telegraf bot instance
70
+ * @param {number} params.fromChatId - Chat ID of the original /solve reply
71
+ * @param {number} params.messageId - Message ID of the (now-edited) reply
72
+ * @param {string} [params.fallbackText] - Plain-text body to send when forwardMessage is rejected
73
+ * @param {Object} [params.fallbackOptions] - Telegram sendMessage options for fallback
74
+ * @param {Set<number>} [params.skipUserIds] - Users that should not receive the notification (e.g. requester is already in the chat)
75
+ * @param {boolean} [params.verbose]
76
+ * @returns {Promise<{forwarded: number, sent: number, skipped: number, failures: Array}>}
77
+ */
78
+ export async function notifySubscribers({ bot, fromChatId, messageId, fallbackText = '', fallbackOptions = {}, skipUserIds = null, verbose = false } = {}) {
79
+ const summary = { forwarded: 0, sent: 0, skipped: 0, failures: [] };
80
+ if (!bot || !bot.telegram) {
81
+ if (verbose) console.log('[VERBOSE] notifySubscribers: missing bot/telegram, skipping');
82
+ return summary;
83
+ }
84
+
85
+ for (const [userId, info] of subscribers.entries()) {
86
+ if (skipUserIds && skipUserIds.has(userId)) {
87
+ summary.skipped += 1;
88
+ if (verbose) console.log(`[VERBOSE] notifySubscribers: skipping user ${userId} (in skip set)`);
89
+ continue;
90
+ }
91
+
92
+ let forwarded = false;
93
+ if (fromChatId !== null && fromChatId !== undefined && messageId !== null && messageId !== undefined) {
94
+ try {
95
+ await bot.telegram.forwardMessage(userId, fromChatId, messageId);
96
+ summary.forwarded += 1;
97
+ forwarded = true;
98
+ if (verbose) {
99
+ console.log(`[VERBOSE] notifySubscribers: forwarded to user ${userId} (${info.username || info.firstName || 'unknown'})`);
100
+ }
101
+ } catch (error) {
102
+ if (verbose) {
103
+ console.log(`[VERBOSE] notifySubscribers: forwardMessage to ${userId} failed: ${error?.message || error}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ if (forwarded) continue;
109
+
110
+ if (!fallbackText) {
111
+ summary.failures.push({ userId, reason: 'forwardMessage failed and no fallback text supplied' });
112
+ continue;
113
+ }
114
+
115
+ try {
116
+ await bot.telegram.sendMessage(userId, fallbackText, fallbackOptions);
117
+ summary.sent += 1;
118
+ if (verbose) {
119
+ console.log(`[VERBOSE] notifySubscribers: sent fallback message to user ${userId}`);
120
+ }
121
+ } catch (error) {
122
+ summary.failures.push({ userId, reason: error?.message || String(error) });
123
+ if (verbose) {
124
+ console.log(`[VERBOSE] notifySubscribers: sendMessage to ${userId} failed: ${error?.message || error}`);
125
+ }
126
+ }
127
+ }
128
+
129
+ return summary;
130
+ }
131
+
132
+ const SUBSCRIBE_CONFIRMATION = 'šŸ”” *Subscribed* (experimental)\n\n' + 'You will receive a private notification each time a /solve command finishes (in any chat where this bot runs).\n\n' + 'āš ļø Subscriptions are kept in memory and are cleared whenever the bot restarts.\n\n' + 'šŸ’” If notifications never arrive, open a private chat with the bot and send /start so Telegram lets the bot DM you.\n\n' + 'Use /unsubscribe to stop receiving these notifications.';
133
+
134
+ const UNSUBSCRIBE_CONFIRMATION = 'šŸ”• *Unsubscribed*\n\n' + 'You will no longer receive private notifications when /solve commands finish.\n\n' + 'Use /subscribe to resume notifications.';
135
+
136
+ const NOT_SUBSCRIBED_MESSAGE = 'ā„¹ļø You are not subscribed.\n\nUse /subscribe to start receiving private notifications when /solve commands finish.';
137
+
138
+ /**
139
+ * Register /subscribe and /unsubscribe handlers.
140
+ *
141
+ * @param {Object} bot - Telegraf bot instance
142
+ * @param {Object} options - Shared command options (VERBOSE, isOldMessage, ...)
143
+ */
144
+ export function registerSubscribeCommands(bot, options = {}) {
145
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb } = options;
146
+
147
+ async function shouldHandle(ctx, cmdName) {
148
+ VERBOSE && console.log(`[VERBOSE] ${cmdName} command received`);
149
+ if (addBreadcrumb) {
150
+ await addBreadcrumb({
151
+ category: 'telegram.command',
152
+ message: `${cmdName} command received`,
153
+ level: 'info',
154
+ data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
155
+ });
156
+ }
157
+ if (isOldMessage && isOldMessage(ctx)) {
158
+ VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: old message`);
159
+ return false;
160
+ }
161
+ if (isForwardedOrReply && isForwardedOrReply(ctx)) {
162
+ VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: forwarded or reply`);
163
+ return false;
164
+ }
165
+ // Issue #1688: /subscribe and /unsubscribe work in both private and group chats.
166
+ // In group chats we still require chat/topic authorization so unauthorized
167
+ // chats cannot use the bot to spam.
168
+ const chatType = ctx.chat?.type;
169
+ const isPrivateChat = chatType === 'private';
170
+ if (!isPrivateChat && isTopicAuthorized && !isTopicAuthorized(ctx)) {
171
+ VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: not authorized`);
172
+ if (buildAuthErrorMessage) {
173
+ await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message?.message_id });
174
+ }
175
+ return false;
176
+ }
177
+ if (!ctx.from?.id) {
178
+ VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: no user id on update`);
179
+ return false;
180
+ }
181
+ return true;
182
+ }
183
+
184
+ bot.command('subscribe', async ctx => {
185
+ if (!(await shouldHandle(ctx, '/subscribe'))) return;
186
+
187
+ const userId = ctx.from.id;
188
+ const wasNew = addSubscriber(userId, {
189
+ username: ctx.from.username,
190
+ firstName: ctx.from.first_name,
191
+ sourceChatId: ctx.chat?.id ?? null,
192
+ });
193
+
194
+ let message = SUBSCRIBE_CONFIRMATION;
195
+ if (!wasNew) {
196
+ message = 'ā„¹ļø You are already subscribed.\n\nUse /unsubscribe to stop receiving private notifications when /solve commands finish.';
197
+ }
198
+
199
+ await ctx.reply(message, {
200
+ parse_mode: 'Markdown',
201
+ reply_to_message_id: ctx.message?.message_id,
202
+ });
203
+
204
+ VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) added (new=${wasNew}); total=${getSubscriberCount()}`);
205
+ });
206
+
207
+ bot.command('unsubscribe', async ctx => {
208
+ if (!(await shouldHandle(ctx, '/unsubscribe'))) return;
209
+
210
+ const userId = ctx.from.id;
211
+ const wasRemoved = removeSubscriber(userId);
212
+
213
+ await ctx.reply(wasRemoved ? UNSUBSCRIBE_CONFIRMATION : NOT_SUBSCRIBED_MESSAGE, {
214
+ parse_mode: 'Markdown',
215
+ reply_to_message_id: ctx.message?.message_id,
216
+ });
217
+
218
+ VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) removed=${wasRemoved}; total=${getSubscriberCount()}`);
219
+ });
220
+ }
@@ -52,7 +52,38 @@ export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', iso
52
52
  return `ā³ Executing...\n\nšŸ“Š Session: \`${sessionName}\`${isolationInfo}${details}`;
53
53
  }
54
54
 
55
- export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '' } = {}) {
55
+ /**
56
+ * Append an extra "Pull request:" line to an existing infoBlock when an issue's
57
+ * /solve session has produced a PR. Idempotent — already present URLs are not
58
+ * duplicated.
59
+ *
60
+ * @param {string} infoBlock - Existing infoBlock (already contains an Issue: line)
61
+ * @param {string|null} pullRequestUrl - PR URL discovered after the session completed
62
+ * @returns {string} New infoBlock
63
+ *
64
+ * @see https://github.com/link-assistant/hive-mind/issues/1688
65
+ */
66
+ export function appendPullRequestLine(infoBlock, pullRequestUrl) {
67
+ if (!pullRequestUrl || !infoBlock) return infoBlock || '';
68
+ if (infoBlock.includes(pullRequestUrl)) return infoBlock;
69
+
70
+ const lines = infoBlock.split('\n');
71
+ let lastUrlLineIdx = -1;
72
+ for (let i = 0; i < lines.length; i++) {
73
+ if (/^(Issue|Pull request|URL):\s/.test(lines[i])) {
74
+ lastUrlLineIdx = i;
75
+ }
76
+ }
77
+ const prLine = `Pull request: ${pullRequestUrl}`;
78
+ if (lastUrlLineIdx === -1) {
79
+ return `${infoBlock}\n${prLine}`;
80
+ }
81
+ const before = lines.slice(0, lastUrlLineIdx + 1);
82
+ const after = lines.slice(lastUrlLineIdx + 1);
83
+ return [...before, prLine, ...after].join('\n');
84
+ }
85
+
86
+ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '', pullRequestUrl = null } = {}) {
56
87
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
57
88
  const failed = finalExitCode !== null && finalExitCode !== 0;
58
89
  const statusEmoji = failed ? 'āŒ' : 'āœ…';
@@ -61,7 +92,10 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
61
92
  const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
62
93
  const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
63
94
  const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
64
- const resolvedInfoBlock = infoBlock || sessionInfo?.infoBlock || '';
95
+ let resolvedInfoBlock = infoBlock || sessionInfo?.infoBlock || '';
96
+ // Issue #1688: When the agent created a PR for an issue-driven /solve, append
97
+ // a 'Pull request:' line so the completion message includes both Issue and PR links.
98
+ if (pullRequestUrl) resolvedInfoBlock = appendPullRequestLine(resolvedInfoBlock, pullRequestUrl);
65
99
  const details = resolvedInfoBlock ? `\n\n${resolvedInfoBlock}` : '';
66
100
 
67
101
  let message = `${statusEmoji} *${statusText}*\n\n`;