@link-assistant/hive-mind 1.56.17 → 1.56.19

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.56.19
4
+
5
+ ### Patch Changes
6
+
7
+ - 0da8eba: Add a `/log` Telegram command that lets a chat owner pull the on-disk log of a `$` isolation session (`screen`, `tmux`, `docker`). The command accepts `/log <UUID>` directly or `/log` as a reply to any session message that contains a session UUID, validates the id with `$ --status`, derives the log path from start-command's `logPath` field, and uploads the file as a reply to the user. Logs from public GitHub repositories are uploaded to the same chat; logs from private (or unknown-visibility) repositories are sent via direct message after forwarding the originating session message into the DM, so private logs never leak into public chats. Access is restricted to the chat owner (Telegram `creator` status), matching the existing `/start`, `/stop`, and `/top` policy.
8
+
9
+ ## 1.56.18
10
+
11
+ ### Patch Changes
12
+
13
+ - 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)
14
+
3
15
  ## 1.56.17
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.56.17",
3
+ "version": "1.56.19",
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-1686-log-command.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",
@@ -41,17 +41,18 @@ export function generateSessionId() {
41
41
  * Keep the parser tolerant so completion monitoring survives either format.
42
42
  *
43
43
  * @param {string} output - Raw stdout from `$ --status`
44
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, raw: string}}
44
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, raw: string}}
45
45
  */
46
46
  export function parseSessionStatusOutput(output) {
47
47
  const raw = (output || '').trim();
48
48
  if (!raw) {
49
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
49
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
50
50
  }
51
51
 
52
52
  try {
53
53
  const parsed = JSON.parse(raw);
54
54
  const data = Array.isArray(parsed) ? parsed[0] : parsed;
55
+ const isolationFromOptions = typeof data?.options?.isolation === 'string' ? data.options.isolation.toLowerCase() : null;
55
56
  return {
56
57
  exists: true,
57
58
  uuid: data?.uuid || null,
@@ -60,6 +61,10 @@ export function parseSessionStatusOutput(output) {
60
61
  startTime: data?.startTime || null,
61
62
  endTime: data?.endTime || null,
62
63
  currentTime: data?.currentTime || null,
64
+ logPath: data?.logPath || null,
65
+ command: data?.command || null,
66
+ isolation: typeof data?.isolation === 'string' ? data.isolation.toLowerCase() : isolationFromOptions,
67
+ workingDirectory: data?.workingDirectory || null,
63
68
  raw,
64
69
  };
65
70
  } catch {
@@ -87,6 +92,10 @@ export function parseSessionStatusOutput(output) {
87
92
  startTime: readField('startTime'),
88
93
  endTime: readField('endTime'),
89
94
  currentTime: readField('currentTime'),
95
+ logPath: readField('logPath'),
96
+ command: readField('command'),
97
+ isolation: readField('isolation')?.toLowerCase() || null,
98
+ workingDirectory: readField('workingDirectory'),
90
99
  raw,
91
100
  };
92
101
  }
@@ -213,7 +222,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
213
222
  if (verbose) {
214
223
  console.log('[VERBOSE] isolation-runner: Cannot query status - $ binary not found');
215
224
  }
216
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
225
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
217
226
  }
218
227
 
219
228
  try {
@@ -230,7 +239,7 @@ export async function querySessionStatus(sessionId, verbose = false) {
230
239
  if (verbose) {
231
240
  console.log(`[VERBOSE] isolation-runner: Status query error: ${error.message}`);
232
241
  }
233
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, raw: '' };
242
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, raw: '' };
234
243
  }
235
244
  }
236
245
 
@@ -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
 
@@ -88,6 +89,21 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
88
89
  }
89
90
  }
90
91
 
92
+ /**
93
+ * Look up the in-memory record for a session id (UUID for isolation sessions
94
+ * or the screen session name for non-isolation sessions). Returns null when no
95
+ * record exists — for example, after a process restart or for sessions that
96
+ * were never tracked through the Telegram bot. Used by `/log` to discover the
97
+ * originating chat id and the GitHub URL associated with a session.
98
+ *
99
+ * @param {string} sessionName
100
+ * @returns {Object|null}
101
+ */
102
+ export function getTrackedSessionInfo(sessionName) {
103
+ if (!sessionName) return null;
104
+ return activeSessions.get(sessionName) || null;
105
+ }
106
+
91
107
  /**
92
108
  * Get the number of active sessions being tracked
93
109
  * @param {boolean} verbose - Whether to log verbose output
@@ -251,6 +267,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
251
267
 
252
268
  try {
253
269
  const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
270
+
271
+ // Issue #1688: When the original /solve URL was an issue, look up the
272
+ // linked PR so the completion message can include both an `Issue:` and
273
+ // a `Pull request:` line. Failures are logged and ignored — the
274
+ // notification still goes out without the PR line.
275
+ let pullRequestUrl = null;
276
+ try {
277
+ pullRequestUrl = await resolvePullRequestUrlForSession(sessionInfo, { verbose, lookupLinkedPullRequest: options.lookupLinkedPullRequest });
278
+ } catch (lookupError) {
279
+ if (verbose) {
280
+ console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
281
+ }
282
+ }
283
+
254
284
  const message = formatSessionCompletionMessage({
255
285
  sessionName,
256
286
  sessionInfo,
@@ -258,13 +288,44 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
258
288
  observedEndTime: new Date(),
259
289
  exitCode: finalExitCode,
260
290
  infoBlock: sessionInfo?.infoBlock || '',
291
+ pullRequestUrl,
261
292
  });
262
293
 
263
294
  // Update the original reply message if messageId is available, otherwise send new message
295
+ let notifyFromChatId = null;
296
+ let notifyMessageId = null;
264
297
  if (sessionInfo.messageId) {
265
298
  await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
299
+ notifyFromChatId = sessionInfo.chatId;
300
+ notifyMessageId = sessionInfo.messageId;
266
301
  } else {
267
- await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
302
+ const sent = await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
303
+ notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
304
+ notifyMessageId = sent?.message_id || null;
305
+ }
306
+
307
+ // Issue #1688: forward the same completion message to every /subscribe-d user
308
+ // in their private chat with the bot. Failures are logged but don't block
309
+ // completion of the parent session.
310
+ if (getSubscriberCount() > 0 && notifyFromChatId && notifyMessageId) {
311
+ try {
312
+ const skipUserIds = new Set();
313
+ if (sessionInfo?.requesterUserId) skipUserIds.add(sessionInfo.requesterUserId);
314
+ const summary = await notifySubscribers({
315
+ bot,
316
+ fromChatId: notifyFromChatId,
317
+ messageId: notifyMessageId,
318
+ fallbackText: message,
319
+ fallbackOptions: { parse_mode: 'Markdown' },
320
+ skipUserIds,
321
+ verbose,
322
+ });
323
+ if (verbose) {
324
+ console.log(`[VERBOSE] Subscribe notify summary for ${sessionName}: forwarded=${summary.forwarded}, sent=${summary.sent}, skipped=${summary.skipped}, failures=${summary.failures.length}`);
325
+ }
326
+ } catch (notifyError) {
327
+ console.error(`[session-monitor] notifySubscribers failed for ${sessionName}:`, notifyError);
328
+ }
268
329
  }
269
330
 
270
331
  completeSession(sessionName, finalExitCode || 0, verbose);
@@ -285,6 +346,51 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
285
346
  }
286
347
  }
287
348
 
349
+ /**
350
+ * Look up the URL of a pull request linked to the issue this session worked on.
351
+ * Returns null when the session was already operating on a PR, the URL context
352
+ * is missing, or no linked PR exists.
353
+ *
354
+ * Lazy-loads the GitHub batch helper so unrelated tests/imports don't pull
355
+ * GitHub deps. Tests can override the lookup via `options.lookupLinkedPullRequest`.
356
+ *
357
+ * @param {Object} sessionInfo
358
+ * @param {Object} [options]
359
+ * @param {boolean} [options.verbose]
360
+ * @param {Function} [options.lookupLinkedPullRequest] - Optional override `(ctx) => Promise<string|null>`
361
+ * @returns {Promise<string|null>} PR URL or null
362
+ *
363
+ * @see https://github.com/link-assistant/hive-mind/issues/1688
364
+ */
365
+ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, lookupLinkedPullRequest = null } = {}) {
366
+ const ctx = sessionInfo?.urlContext;
367
+ if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
368
+ return null;
369
+ }
370
+
371
+ if (typeof lookupLinkedPullRequest === 'function') {
372
+ return await lookupLinkedPullRequest(ctx);
373
+ }
374
+
375
+ try {
376
+ const { batchCheckPullRequestsForIssues } = await import('./github.lib.mjs');
377
+ const result = await batchCheckPullRequestsForIssues(ctx.owner, ctx.repo, [ctx.number]);
378
+ const linkedPRs = result?.[ctx.number]?.linkedPRs || [];
379
+ if (linkedPRs.length > 0 && linkedPRs[0].url) {
380
+ if (verbose) {
381
+ console.log(`[VERBOSE] Found linked PR ${linkedPRs[0].url} for issue ${ctx.owner}/${ctx.repo}#${ctx.number}`);
382
+ }
383
+ return linkedPRs[0].url;
384
+ }
385
+ } catch (error) {
386
+ if (verbose) {
387
+ console.log(`[VERBOSE] batchCheckPullRequestsForIssues failed for ${ctx.owner}/${ctx.repo}#${ctx.number}: ${error?.message || error}`);
388
+ }
389
+ throw error;
390
+ }
391
+ return null;
392
+ }
393
+
288
394
  /**
289
395
  * Start the session monitoring interval
290
396
  * @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
- message += '*/stop* - Stop accepting new tasks (owner only)\n';
667
- message += '*/start* - Resume accepting tasks (owner only)\n\n';
668
- message += 'šŸ”” *Session Notifications:* The bot monitors sessions and notifies when they complete.\n';
665
+ message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
666
+ message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n\n';
667
+ message += 'šŸ”” *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\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';
@@ -764,7 +763,6 @@ bot.command('version', async ctx => {
764
763
  await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, 'šŸ¤– *Version Information*\n\n' + formatVersionMessage(result.versions), { parse_mode: 'Markdown' });
765
764
  });
766
765
 
767
- // Register external command modules (keeps telegram-bot.mjs under line limit)
768
766
  const { registerAcceptInvitesCommand } = await import('./telegram-accept-invitations.lib.mjs');
769
767
  const sharedCommandOpts = { VERBOSE, isOldMessage, isForwardedOrReply, isGroupChat: _isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage };
770
768
  registerAcceptInvitesCommand(bot, sharedCommandOpts);
@@ -772,6 +770,8 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
772
770
  registerMergeCommand(bot, sharedCommandOpts);
773
771
  const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
774
772
  const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
773
+ const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
774
+ registerSubscribeCommands(bot, sharedCommandOpts);
775
775
 
776
776
  // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
777
777
  async function handleSolveCommand(ctx) {
@@ -983,10 +983,10 @@ async function handleSolveCommand(ctx) {
983
983
  const normalizedUrl = validation.parsed.normalized;
984
984
 
985
985
  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
986
+ // #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
988
987
  const userOptionsRaw = userArgs.slice(1).join(' ');
989
- let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(normalizedUrl)}`;
988
+ const urlLabel = validation.parsed?.type === 'pull' ? 'Pull request' : 'Issue';
989
+ let infoBlock = `Requested by: ${requester}\n${urlLabel}: ${escapeMarkdown(normalizedUrl)}`;
990
990
  if (userOptionsRaw) infoBlock += `\n\nšŸ›  Options: ${escapeMarkdown(userOptionsRaw)}`;
991
991
  if (solveOverrides.length > 0) infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}šŸ”’ Locked options: ${escapeMarkdown(solveOverrides.join(' '))}`;
992
992
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
@@ -1012,12 +1012,15 @@ async function handleSolveCommand(ctx) {
1012
1012
  return;
1013
1013
  }
1014
1014
 
1015
+ // Issue #1688: parsed URL context lets the completion message look up linked PRs.
1016
+ 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;
1017
+
1015
1018
  const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
1016
1019
  if (check.canStart && toolQueuedCount === 0) {
1017
1020
  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);
1021
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext);
1019
1022
  } else {
1020
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
1023
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext });
1021
1024
  let queueMessage = `šŸ“‹ Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
1022
1025
  if (check.reason) queueMessage += `\n\nā³ Waiting: ${escapeMarkdown(check.reason)}`;
1023
1026
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
@@ -1187,8 +1190,10 @@ bot.command(/^hive$/i, handleHiveCommand);
1187
1190
 
1188
1191
  const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1189
1192
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1193
+ const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1190
1194
  registerTopCommand(bot, sharedCommandOpts);
1191
1195
  registerStartStopCommands(bot, sharedCommandOpts);
1196
+ await registerLogCommand(bot, sharedCommandOpts);
1192
1197
 
1193
1198
  // Add message listener for verbose debugging
1194
1199
  if (VERBOSE) {
@@ -1252,7 +1257,7 @@ bot.on('message', async (ctx, next) => {
1252
1257
  }
1253
1258
  }
1254
1259
 
1255
- // Check if this is a command we handle
1260
+ // /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback — Telegraf's bot.command() is sufficient.
1256
1261
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1257
1262
  const handlers = { ...solveHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand };
1258
1263
 
@@ -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);
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Telegram /log command implementation
3
+ *
4
+ * Lets a chat owner pull the log of an isolation session that was launched
5
+ * through the `$` (start-command) CLI. The session is identified by its UUID,
6
+ * either passed as `/log <UUID>` or extracted from a message that the
7
+ * `/log` command is replying to.
8
+ *
9
+ * Privacy guarantees:
10
+ * - Only the chat creator (`status === 'creator'`) may invoke `/log`.
11
+ * - Logs from public GitHub repositories may be uploaded into the chat where
12
+ * `/log` was issued.
13
+ * - Logs from private GitHub repositories — and logs whose repository
14
+ * visibility we cannot determine — are sent to the user via direct message
15
+ * only, after forwarding the original message that contained the session id.
16
+ * - Currently only sessions launched with one of the `$` isolation backends
17
+ * (`screen`, `tmux`, `docker`) are supported. Direct (non-isolation) sessions
18
+ * are rejected with a clear message.
19
+ *
20
+ * @see https://github.com/link-assistant/hive-mind/issues/1686
21
+ */
22
+
23
+ import path from 'path';
24
+ import fs from 'fs/promises';
25
+ import { constants as fsConstants } from 'fs';
26
+
27
+ const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
28
+ const ISOLATION_BACKENDS = new Set(['screen', 'tmux', 'docker']);
29
+ // Telegram bots may upload documents up to 50 MB via sendDocument.
30
+ // https://core.telegram.org/bots/api#senddocument
31
+ const TELEGRAM_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024;
32
+
33
+ /**
34
+ * Extract the first RFC 4122 v4-shaped UUID found in `text`.
35
+ *
36
+ * @param {string|null|undefined} text
37
+ * @returns {string|null}
38
+ */
39
+ export function extractSessionIdFromText(text) {
40
+ if (!text || typeof text !== 'string') return null;
41
+ const match = text.match(UUID_RE);
42
+ return match ? match[1].toLowerCase() : null;
43
+ }
44
+
45
+ /**
46
+ * Decide where the log for a session should be delivered.
47
+ *
48
+ * Inputs:
49
+ * - `statusResult`: parsed result of `$ --status <uuid>` (see
50
+ * `parseSessionStatusOutput` in `isolation-runner.lib.mjs`).
51
+ * - `sessionInfo`: in-memory record from the Telegram session monitor, or null.
52
+ * - `repoVisibility`: result of `detectRepositoryVisibility(owner, repo)`, or
53
+ * null when the repo could not be identified.
54
+ * - `chatType`: Telegram chat type where `/log` was invoked
55
+ * (`'private'` | `'group'` | `'supergroup'` | `'channel'`).
56
+ *
57
+ * Output: `{ destination, reason, isolationBackend }` where `destination` is
58
+ * one of `'chat'` (deliver in the same chat), `'dm'` (deliver in DM),
59
+ * `'reject'` (don't deliver). `reason` is a short, user-facing string.
60
+ *
61
+ * @returns {{destination: 'chat'|'dm'|'reject', reason: string, isolationBackend: string|null}}
62
+ */
63
+ export function decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType }) {
64
+ if (!statusResult || !statusResult.exists) {
65
+ return { destination: 'reject', reason: 'Unknown session id (start-command does not know about it).', isolationBackend: null };
66
+ }
67
+
68
+ // Determine isolation backend. Prefer the in-memory record (which knows what
69
+ // we asked `$` to use), fall back to whatever `$ --status` reports.
70
+ const isolationBackend = (sessionInfo?.isolationBackend || statusResult.isolation || '').toLowerCase() || null;
71
+ if (!isolationBackend || !ISOLATION_BACKENDS.has(isolationBackend)) {
72
+ return {
73
+ destination: 'reject',
74
+ reason: 'This command currently supports only sessions launched with `$` isolation (screen / tmux / docker).',
75
+ isolationBackend: isolationBackend || null,
76
+ };
77
+ }
78
+
79
+ // Privacy decision — fail closed when in doubt.
80
+ const isPublic = repoVisibility?.isPublic === true;
81
+ const visibilityKnown = !!repoVisibility && repoVisibility.visibility !== null;
82
+
83
+ if (isPublic && visibilityKnown) {
84
+ if (chatType === 'private') {
85
+ // /log was invoked in DM. Deliver in DM regardless of repo visibility.
86
+ return { destination: 'dm', reason: 'Public repository, delivering in DM (command was sent in a private chat).', isolationBackend };
87
+ }
88
+ return { destination: 'chat', reason: 'Public repository, delivering in chat.', isolationBackend };
89
+ }
90
+
91
+ // Private OR unknown visibility — never leak in a public chat.
92
+ return {
93
+ destination: 'dm',
94
+ reason: visibilityKnown ? 'Private repository — delivering via direct message.' : 'Repository visibility could not be determined — delivering via direct message (fail-closed).',
95
+ isolationBackend,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Resolve the on-disk log path for a session.
101
+ *
102
+ * Prefers the `logPath` field reported by `$ --status` (always correct when
103
+ * supported). Falls back to start-command's documented layout if the field is
104
+ * missing.
105
+ *
106
+ * @returns {string|null}
107
+ */
108
+ export function resolveLogPath({ statusResult, isolationBackend }) {
109
+ if (statusResult?.logPath) return statusResult.logPath;
110
+ const uuid = statusResult?.uuid;
111
+ if (!uuid) return null;
112
+ if (isolationBackend && ISOLATION_BACKENDS.has(isolationBackend)) {
113
+ return path.join('/tmp/start-command/logs/isolation', isolationBackend, `${uuid}.log`);
114
+ }
115
+ return path.join('/tmp/start-command/logs/direct', `${uuid}.log`);
116
+ }
117
+
118
+ async function fileExists(filePath) {
119
+ try {
120
+ await fs.access(filePath, fsConstants.R_OK);
121
+ return true;
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ async function fileSize(filePath) {
128
+ try {
129
+ const stat = await fs.stat(filePath);
130
+ return stat.size;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Registers the /log command handler with the bot.
138
+ *
139
+ * Dependencies (`querySessionStatus`, `getTrackedSessionInfo`,
140
+ * `detectRepositoryVisibility`, `parseGitHubUrl`) are lazy-loaded from the
141
+ * existing libraries by default; tests pass mocked versions through `options`.
142
+ *
143
+ * @param {Object} bot - Telegraf bot instance
144
+ * @param {Object} options
145
+ * @param {boolean} [options.VERBOSE]
146
+ * @param {Function} options.isOldMessage
147
+ * @param {Function} options.isChatAuthorized
148
+ * @param {Function} [options.isTopicAuthorized]
149
+ * @param {Function} [options.buildAuthErrorMessage]
150
+ * @param {Function} [options.querySessionStatus] - Override for tests
151
+ * @param {Function} [options.getTrackedSessionInfo] - Override for tests
152
+ * @param {Function} [options.detectRepositoryVisibility] - Override for tests
153
+ * @param {Function} [options.parseGitHubUrl] - Override for tests
154
+ */
155
+ export async function registerLogCommand(bot, options) {
156
+ const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
157
+ const querySessionStatus = options.querySessionStatus || (await import('./isolation-runner.lib.mjs')).querySessionStatus;
158
+ const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
159
+ const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
160
+ const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
161
+
162
+ bot.command('log', async ctx => {
163
+ VERBOSE && console.log('[VERBOSE] /log command received');
164
+
165
+ if (isOldMessage && isOldMessage(ctx)) {
166
+ VERBOSE && console.log('[VERBOSE] /log ignored: old message');
167
+ return;
168
+ }
169
+
170
+ const chat = ctx.chat;
171
+ const message = ctx.message;
172
+ if (!chat || !message) return;
173
+
174
+ const chatType = chat.type;
175
+ const chatId = chat.id;
176
+
177
+ // Extract the session id. Priority: explicit argument, then reply text.
178
+ const directSessionId = extractSessionIdFromText(message.text || '');
179
+ const repliedTo = message.reply_to_message;
180
+ const replySessionId = repliedTo ? extractSessionIdFromText(repliedTo.text || repliedTo.caption || '') : null;
181
+ const sessionId = directSessionId || replySessionId;
182
+
183
+ if (!sessionId) {
184
+ await ctx.reply('āŒ /log requires a session id.\n\nUsage:\n• `/log <UUID>` — fetch a specific session log\n• Reply to a session message with `/log` — fetch the session referenced in that message', {
185
+ parse_mode: 'Markdown',
186
+ reply_to_message_id: message.message_id,
187
+ });
188
+ return;
189
+ }
190
+
191
+ // Authorization. /log is only available to chat owners. In private chats
192
+ // there is no "creator" status — the user is implicitly the owner of their
193
+ // own DM, so we allow it. We still apply the optional allowlist used by
194
+ // other commands so a private bot deployment can lock /log to known users.
195
+ if (chatType === 'private') {
196
+ // No further auth required beyond the optional whitelist applied below.
197
+ } else {
198
+ try {
199
+ const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
200
+ if (!member || member.status !== 'creator') {
201
+ VERBOSE && console.log('[VERBOSE] /log rejected: not chat owner');
202
+ await ctx.reply('āŒ /log is only available to the chat owner.', { reply_to_message_id: message.message_id });
203
+ return;
204
+ }
205
+ } catch (error) {
206
+ console.error('[ERROR] /log: getChatMember failed:', error);
207
+ await ctx.reply('āŒ Failed to verify permissions for /log.', { reply_to_message_id: message.message_id });
208
+ return;
209
+ }
210
+ }
211
+
212
+ if (isChatAuthorized && !isChatAuthorized(chatId)) {
213
+ // Topic-aware fallback (used elsewhere in this repo for forum topics).
214
+ if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
215
+ VERBOSE && console.log('[VERBOSE] /log rejected: chat not authorized');
216
+ const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `āŒ This chat (ID: ${chatId}) is not authorized.`;
217
+ await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
218
+ return;
219
+ }
220
+ }
221
+
222
+ // 1. Validate the session id with $ --status.
223
+ let statusResult;
224
+ try {
225
+ statusResult = await querySessionStatus(sessionId, VERBOSE);
226
+ } catch (error) {
227
+ console.error('[ERROR] /log: querySessionStatus failed:', error);
228
+ await ctx.reply(`āŒ Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
229
+ return;
230
+ }
231
+
232
+ if (!statusResult || !statusResult.exists) {
233
+ await ctx.reply(`āŒ Session \`${sessionId}\` is not known to start-command.\n\nUse the session id from a \`šŸ“Š Session: <uuid>\` line in one of the bot's status messages.`, {
234
+ parse_mode: 'Markdown',
235
+ reply_to_message_id: message.message_id,
236
+ });
237
+ return;
238
+ }
239
+
240
+ // 2. Look up tracked metadata (for repo URL and original chat).
241
+ const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
242
+
243
+ // 3. Decide repo visibility — prefer the URL we tracked at launch time.
244
+ let repoVisibility = null;
245
+ let repoUrlDescription = null;
246
+ const trackedUrl = sessionInfo?.url || null;
247
+ if (trackedUrl) {
248
+ const parsed = parseGitHubUrl ? parseGitHubUrl(trackedUrl) : null;
249
+ if (parsed && parsed.valid && parsed.owner && parsed.repo) {
250
+ repoUrlDescription = `${parsed.owner}/${parsed.repo}`;
251
+ try {
252
+ repoVisibility = await detectRepositoryVisibility(parsed.owner, parsed.repo);
253
+ } catch (error) {
254
+ console.error('[ERROR] /log: detectRepositoryVisibility failed:', error);
255
+ repoVisibility = null;
256
+ }
257
+ }
258
+ }
259
+
260
+ // 4. Decide the destination.
261
+ const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType });
262
+ if (decision.destination === 'reject') {
263
+ await ctx.reply(`āŒ ${decision.reason}`, { reply_to_message_id: message.message_id });
264
+ return;
265
+ }
266
+
267
+ // 5. Resolve and validate the on-disk log file.
268
+ const logPath = resolveLogPath({ statusResult, isolationBackend: decision.isolationBackend });
269
+ if (!logPath) {
270
+ await ctx.reply('āŒ Could not determine the log file path for this session.', { reply_to_message_id: message.message_id });
271
+ return;
272
+ }
273
+ if (!(await fileExists(logPath))) {
274
+ await ctx.reply(`āŒ Log file does not exist on disk:\n\`${logPath}\`\n\nThe session may have been cleaned up by the host or the isolation backend.`, {
275
+ parse_mode: 'Markdown',
276
+ reply_to_message_id: message.message_id,
277
+ });
278
+ return;
279
+ }
280
+ const size = await fileSize(logPath);
281
+ if (size !== null && size > TELEGRAM_DOCUMENT_MAX_BYTES) {
282
+ await ctx.reply(`āŒ Log file is ${(size / (1024 * 1024)).toFixed(1)} MB which exceeds Telegram's 50 MB document upload limit.\n\nFile path on host: \`${logPath}\``, {
283
+ parse_mode: 'Markdown',
284
+ reply_to_message_id: message.message_id,
285
+ });
286
+ return;
287
+ }
288
+
289
+ const filename = path.basename(logPath);
290
+ const captionLines = [`šŸ“ Log for session \`${sessionId}\``];
291
+ if (decision.isolationBackend) captionLines.push(`šŸ”’ Isolation: \`${decision.isolationBackend}\``);
292
+ if (statusResult.status) captionLines.push(`Status: \`${statusResult.status}\``);
293
+ if (repoUrlDescription) captionLines.push(`Repo: \`${repoUrlDescription}\``);
294
+ captionLines.push(`Privacy: ${decision.reason}`);
295
+ const caption = captionLines.join('\n');
296
+
297
+ if (decision.destination === 'chat') {
298
+ // Public repository → reply with the document directly in the chat.
299
+ try {
300
+ await ctx.replyWithDocument({ source: logPath, filename }, { reply_to_message_id: message.message_id, caption, parse_mode: 'Markdown' });
301
+ } catch (error) {
302
+ console.error('[ERROR] /log: replyWithDocument failed:', error);
303
+ await ctx.reply(`āŒ Failed to upload log: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
304
+ }
305
+ return;
306
+ }
307
+
308
+ // DM flow: forward the originating message into DM (so the audit chain
309
+ // is preserved), then reply to that forwarded message with the log file.
310
+ const userId = ctx.from?.id;
311
+ if (!userId) {
312
+ await ctx.reply('āŒ Cannot deliver the log via DM: missing user id.', { reply_to_message_id: message.message_id });
313
+ return;
314
+ }
315
+
316
+ let forwardedMessageId = null;
317
+ try {
318
+ // Forward the message that contains the session id (the reply target if
319
+ // any, otherwise the /log message itself).
320
+ const forwardSource = repliedTo || message;
321
+ const forwardedFromChatId = forwardSource === repliedTo ? chatId : chatId;
322
+ const forwardedSourceMessageId = forwardSource.message_id;
323
+ try {
324
+ const forwarded = await ctx.telegram.forwardMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
325
+ forwardedMessageId = forwarded?.message_id || null;
326
+ } catch (forwardError) {
327
+ // forwardMessage can fail if the user has not opened a DM with the bot
328
+ // yet, or the source chat blocks forwards. Fall back to copyMessage,
329
+ // which works without a forward header.
330
+ try {
331
+ const copied = await ctx.telegram.copyMessage(userId, forwardedFromChatId, forwardedSourceMessageId);
332
+ forwardedMessageId = copied?.message_id || null;
333
+ } catch (copyError) {
334
+ console.error('[ERROR] /log: forward/copyMessage to DM failed:', forwardError, copyError);
335
+ // Fall through — we can still try sendDocument without a reply ref.
336
+ }
337
+ }
338
+ } catch (error) {
339
+ console.error('[ERROR] /log: DM forwarding step failed:', error);
340
+ }
341
+
342
+ try {
343
+ const replyOpts = forwardedMessageId ? { reply_to_message_id: forwardedMessageId, caption, parse_mode: 'Markdown' } : { caption, parse_mode: 'Markdown' };
344
+ await ctx.telegram.sendDocument(userId, { source: logPath, filename }, replyOpts);
345
+ } catch (error) {
346
+ console.error('[ERROR] /log: sendDocument to DM failed:', error);
347
+ // Tell the user, in their original chat, that DM delivery failed
348
+ // (commonly because they have not started a chat with the bot).
349
+ const friendly = error?.code === 403 || /chat not found|bot can't initiate conversation/i.test(error?.message || '') ? 'I could not send you a DM. Please open a private chat with me and send /start, then try again.' : `Failed to send the log via DM: ${error.message || String(error)}`;
350
+ await ctx.reply(`āŒ ${friendly}`, { reply_to_message_id: message.message_id });
351
+ return;
352
+ }
353
+
354
+ // Acknowledge in the original chat (only if it wasn't already a DM).
355
+ if (chatType !== 'private') {
356
+ try {
357
+ await ctx.reply(`šŸ“¬ Sent the log for \`${sessionId}\` to your direct messages (private repository).`, {
358
+ parse_mode: 'Markdown',
359
+ reply_to_message_id: message.message_id,
360
+ });
361
+ } catch (error) {
362
+ console.error('[ERROR] /log: failed to acknowledge in chat:', error);
363
+ }
364
+ }
365
+ });
366
+ }
367
+
368
+ export const __INTERNAL_FOR_TESTS__ = {
369
+ UUID_RE,
370
+ TELEGRAM_DOCUMENT_MAX_BYTES,
371
+ ISOLATION_BACKENDS,
372
+ };
@@ -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`;