@link-assistant/hive-mind 2.7.3 → 2.8.0

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.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Telegram `/fix` command (issue #1733).
3
+ *
4
+ * Spawns the `fix` CLI in a work session, exactly like `/solve` and `/task` do
5
+ * for their own CLIs. `fix` creates the CI/CD remediation issue and then hands
6
+ * it off to `/solve --development-log --deep-analysis --auto-merge` itself, so
7
+ * this handler only has to validate the request and start the session.
8
+ */
9
+
10
+ import { buildUserMention } from './buildUserMention.lib.mjs';
11
+ import { validateModelName } from './models/index.mjs';
12
+ import { parseFixRepository } from './fix.ci-cd.lib.mjs';
13
+ import { escapeMarkdown } from './telegram-markdown.lib.mjs';
14
+ import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
15
+ import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
16
+ import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
17
+
18
+ export const FIX_COMMAND_NAMES = Object.freeze(['fix']);
19
+
20
+ export function getFixCommandNameFromText(text) {
21
+ if (!text || typeof text !== 'string') return null;
22
+ const firstLine = text.split('\n')[0].trim();
23
+ const match = firstLine.match(/^\/(\w+)(?:@\S+)?(?:\s|$)/);
24
+ const command = match ? match[1].toLowerCase() : null;
25
+ return FIX_COMMAND_NAMES.includes(command) ? command : null;
26
+ }
27
+
28
+ /**
29
+ * `--ci-cd` is the only mode `fix` supports today and it is required by the
30
+ * CLI, so the chat command implies it instead of making every user type it.
31
+ */
32
+ export function applyFixCommandDefaults(args) {
33
+ const hasCiCd = args.includes('--ci-cd');
34
+ return hasCiCd ? args : [...args, '--ci-cd'];
35
+ }
36
+
37
+ export function findFixRepositoryArg(args) {
38
+ return args.find(arg => !arg.startsWith('-') && parseFixRepository(arg)) || null;
39
+ }
40
+
41
+ export function getFixToolFromArgs(args) {
42
+ for (let i = 0; i < args.length; i++) {
43
+ if (args[i] === '--tool' && i + 1 < args.length) return args[i + 1];
44
+ if (args[i].startsWith('--tool=')) return args[i].substring('--tool='.length);
45
+ }
46
+ return 'claude';
47
+ }
48
+
49
+ function getModelFromArgs(args) {
50
+ for (let i = 0; i < args.length; i++) {
51
+ if ((args[i] === '--model' || args[i] === '-m') && i + 1 < args.length) return args[i + 1];
52
+ if (args[i].startsWith('--model=')) return args[i].substring('--model='.length);
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function validateFixModel(args) {
58
+ const model = getModelFromArgs(args);
59
+ if (!model) return null;
60
+ const validation = validateModelName(model, getFixToolFromArgs(args));
61
+ return validation.valid ? null : validation.message;
62
+ }
63
+
64
+ export function buildFixCommandArgs(text) {
65
+ const args = applyFixCommandDefaults(parseCommandArgs(text));
66
+ const repositoryRaw = findFixRepositoryArg(args);
67
+ const repository = repositoryRaw ? parseFixRepository(repositoryRaw) : null;
68
+ return {
69
+ // `fix` reads the repository from the first bare argument, so normalize the
70
+ // shorthand (`owner/repo`) to a full URL and move it to the front.
71
+ args: repository ? moveArgumentToFront(args, repository.url, value => parseFixRepository(value)?.url || value) : args,
72
+ repositoryRaw,
73
+ repository,
74
+ };
75
+ }
76
+
77
+ // Issue #378: inject --language LOCALE into spawn args if no language flag is
78
+ // already present, so spawned fix sessions inherit the user's effective locale.
79
+ function injectLanguageIfMissing(args, locale) {
80
+ if (!locale || !args || !Array.isArray(args)) return args;
81
+ const langFlags = new Set(['--language', '--ui-language', '--work-language']);
82
+ for (const arg of args) {
83
+ const flag = arg.startsWith('--') ? arg.split('=')[0] : null;
84
+ if (flag && langFlags.has(flag)) return args;
85
+ }
86
+ return [...args, '--language', locale];
87
+ }
88
+
89
+ export function registerFixCommand(bot, options) {
90
+ const { VERBOSE, fixEnabled, addBreadcrumb, isOldMessage, isForwardedOrReply, isGroupChat, isTopicAuthorized, buildAuthErrorMessage, isChatStopped, getStoppedChatRejectMessage, safeReply, executeAndUpdateMessage, resolveLocale = null } = options;
91
+
92
+ async function handleFixCommand(ctx) {
93
+ const commandDisplay = '/fix';
94
+ VERBOSE && console.log(`[VERBOSE] ${commandDisplay} command received`);
95
+
96
+ await addBreadcrumb({
97
+ category: 'telegram.command',
98
+ message: `${commandDisplay} command received`,
99
+ level: 'info',
100
+ data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
101
+ });
102
+
103
+ if (!fixEnabled) {
104
+ await ctx.reply('❌ The fix command is disabled on this bot instance.');
105
+ return;
106
+ }
107
+ if (isOldMessage(ctx)) return;
108
+ // Issue #1922: a forwarded or replied-to /fix command must never be
109
+ // re-executed. Unlike /task, /fix takes no input from the replied message,
110
+ // so both cases are ignored.
111
+ if (isForwardedOrReply && isForwardedOrReply(ctx)) {
112
+ VERBOSE && console.log(`[VERBOSE] ${commandDisplay} ignored: forwarded or reply message`);
113
+ return;
114
+ }
115
+ if (!isGroupChat(ctx)) {
116
+ await ctx.reply(`❌ The ${commandDisplay} command only works in group chats. Please add this bot to a group and make it an admin.`, { reply_to_message_id: ctx.message.message_id });
117
+ return;
118
+ }
119
+ if (!isTopicAuthorized(ctx)) {
120
+ await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message.message_id });
121
+ return;
122
+ }
123
+ if (isChatStopped(ctx.chat.id)) {
124
+ await safeReply(ctx, getStoppedChatRejectMessage(ctx.chat.id, 'Fix'), { reply_to_message_id: ctx.message.message_id });
125
+ return;
126
+ }
127
+
128
+ const built = buildFixCommandArgs(ctx.message.text);
129
+ if (!built.repository) {
130
+ await safeReply(ctx, `❌ Missing GitHub repository URL. Usage: \`${commandDisplay} <github-repository-url> [options]\`\n\nExample: \`${commandDisplay} https://github.com/owner/repo\``, { reply_to_message_id: ctx.message.message_id });
131
+ return;
132
+ }
133
+
134
+ const { backend: perCommandIsolation, filteredArgs } = extractIsolationFromArgs(built.args);
135
+ if (perCommandIsolation && !isValidPerCommandIsolation(perCommandIsolation)) {
136
+ await safeReply(ctx, `❌ Invalid --isolation value '${escapeMarkdown(perCommandIsolation)}'. Must be: screen, tmux, or docker`, { reply_to_message_id: ctx.message.message_id });
137
+ return;
138
+ }
139
+
140
+ const modelError = validateFixModel(filteredArgs);
141
+ if (modelError) {
142
+ await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });
143
+ return;
144
+ }
145
+
146
+ const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
147
+ const userOptionsRaw = built.args.slice(1).join(' ');
148
+ let infoBlock = `Requested by: ${requester}\nRepository: ${escapeMarkdown(built.repository.url)}`;
149
+ if (userOptionsRaw) infoBlock += `\n\n🛠 Options: ${escapeMarkdown(userOptionsRaw)}`;
150
+
151
+ const fixUrlContext = { owner: built.repository.owner, repo: built.repository.repo, normalized: built.repository.url };
152
+ const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
153
+ const fixLocale = resolveLocale ? resolveLocale(ctx) : null;
154
+ const argsForExec = injectLanguageIfMissing(filteredArgs, fixLocale);
155
+ await executeAndUpdateMessage(ctx, startingMessage, 'fix', argsForExec, infoBlock, perCommandIsolation || null, getFixToolFromArgs(argsForExec), fixUrlContext);
156
+ }
157
+
158
+ bot.command(
159
+ FIX_COMMAND_NAMES.map(command => new RegExp(`^${command}$`, 'i')),
160
+ handleFixCommand
161
+ );
162
+
163
+ return { handleFixCommand, FIX_COMMAND_NAMES };
164
+ }
@@ -21,6 +21,7 @@ import { resolveMergeTargetItems } from './github-merge-targets.lib.mjs';
21
21
  import { waitForPRReady as waitForPRReadyHelper } from './telegram-merge-wait.lib.mjs';
22
22
  import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
23
23
  import { getProgressBar } from './limits.lib.mjs';
24
+ import { cancellableSleep as cancellableSleepUntil } from './interruptible-sleep.lib.mjs';
24
25
 
25
26
  /**
26
27
  * Status enum for merge queue operations
@@ -495,7 +496,17 @@ export class MergeQueueProcessor {
495
496
  try {
496
497
  // Step 1: Check if PR is mergeable
497
498
  item.status = MergeItemStatus.CHECKING_CI;
498
- const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
499
+ // Issue #2072: pass cancellation down so the UNKNOWN-mergeability retry delay aborts early
500
+ const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose, { isCancelled: () => this.isCancelled });
501
+
502
+ // Issue #2072: a cancel during the mergeability check must skip the PR, not fail it.
503
+ if (mergeableCheck.cancelled || this.isCancelled) {
504
+ item.status = MergeItemStatus.SKIPPED;
505
+ item.error = 'Cancelled';
506
+ this.stats.skipped++;
507
+ this.log(`Skipped PR #${item.pr.number}: cancelled during mergeability check`);
508
+ return;
509
+ }
499
510
 
500
511
  if (mergeableCheck.terminal) {
501
512
  item.status = MergeItemStatus.FAILED;
@@ -893,7 +904,7 @@ export class MergeQueueProcessor {
893
904
  if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
894
905
  return { outcome: 'error', error: error.message };
895
906
  }
896
- await this.cancellableSleep(pollInterval);
907
+ await this.sleep(pollInterval);
897
908
  continue;
898
909
  }
899
910
 
@@ -912,27 +923,12 @@ export class MergeQueueProcessor {
912
923
  }
913
924
  }
914
925
 
915
- await this.cancellableSleep(pollInterval);
926
+ await this.sleep(pollInterval);
916
927
  }
917
928
 
918
929
  return { outcome: 'timeout' };
919
930
  }
920
931
 
921
- /**
922
- * Issue #1807: Sleep helper that bails out as soon as cancellation is
923
- * requested. Used by the auto-resolve poll loop so a `cancel()` call
924
- * doesn't have to wait a full polling interval before taking effect.
925
- */
926
- async cancellableSleep(ms) {
927
- const step = Math.min(ms, 1000);
928
- const deadline = Date.now() + ms;
929
- while (Date.now() < deadline) {
930
- if (this.isCancelled) return;
931
- const remaining = deadline - Date.now();
932
- await this.sleep(Math.min(step, remaining));
933
- }
934
- }
935
-
936
932
  /**
937
933
  * Wait for any active CI runs on the target branch to complete
938
934
  * Issue #1307: Prevents merging while post-merge CI from previous merges is still running
@@ -1456,10 +1452,17 @@ export class MergeQueueProcessor {
1456
1452
  }
1457
1453
 
1458
1454
  /**
1459
- * Sleep helper
1455
+ * Sleep helper.
1456
+ *
1457
+ * Issue #2072: this is the single sleep primitive for the queue, and it is
1458
+ * cancellable — it returns as soon as `cancel()` is called (checked every
1459
+ * 100ms) or SIGINT/SIGTERM arrives, instead of sleeping out the full delay.
1460
+ * Every wait in the queue routes through here, so no stage of `/merge` can
1461
+ * hold up a cancel by more than ~100ms. This supersedes the separate
1462
+ * `cancellableSleep` helper added for #1807.
1460
1463
  */
1461
1464
  sleep(ms) {
1462
- return new Promise(resolve => setTimeout(resolve, ms));
1465
+ return cancellableSleepUntil(ms, () => this.isCancelled);
1463
1466
  }
1464
1467
  }
1465
1468
 
@@ -65,8 +65,14 @@ export async function waitForPRReady(processor, item, initialCheck, options) {
65
65
  await processor.onProgress(processor.getProgressUpdate());
66
66
  }
67
67
 
68
+ // Issue #2072: `processor.sleep` returns early on cancel. The poll interval defaults
69
+ // to 5 minutes, which previously kept `/merge` running long after Cancel was pressed.
68
70
  await processor.sleep(pollIntervalMs);
69
- latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose);
71
+ if (processor.isCancelled) {
72
+ return { success: false, status: 'cancelled', error: 'Cancelled' };
73
+ }
74
+
75
+ latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose, { isCancelled: () => processor.isCancelled });
70
76
  }
71
77
  }
72
78
 
@@ -23,7 +23,7 @@ export function buildSolveQueuedMessage({ locale = null, tool = 'claude', positi
23
23
  return message;
24
24
  }
25
25
 
26
- export function buildTelegramHelpMessage({ locale = null, chatId, chatType = '', chatTitle = '', topicId = null, isStopped = false, stopInfo = null, stopReason = '', solveEnabled = true, taskEnabled = true, hiveEnabled = true, solveOverrides = [], hiveOverrides = [], showLimitsEnabled = false, isolationBackend = null, modelDescription = '', restrictedMode = false, authorized = null, allowTopicHint = '' } = {}) {
26
+ export function buildTelegramHelpMessage({ locale = null, chatId, chatType = '', chatTitle = '', topicId = null, isStopped = false, stopInfo = null, stopReason = '', solveEnabled = true, taskEnabled = true, fixEnabled = true, hiveEnabled = true, solveOverrides = [], hiveOverrides = [], showLimitsEnabled = false, isolationBackend = null, modelDescription = '', restrictedMode = false, authorized = null, allowTopicHint = '' } = {}) {
27
27
  const message = [];
28
28
  addLine(message, 'telegram.help_title', {}, locale);
29
29
  message.push('');
@@ -71,6 +71,16 @@ export function buildTelegramHelpMessage({ locale = null, chatId, chatType = '',
71
71
  message.push('');
72
72
  }
73
73
 
74
+ if (fixEnabled) {
75
+ addLine(message, 'telegram.help_fix_enabled', {}, locale);
76
+ addLine(message, 'telegram.help_fix_usage', {}, locale);
77
+ addLine(message, 'telegram.help_fix_example', {}, locale);
78
+ message.push('');
79
+ } else {
80
+ addLine(message, 'telegram.help_fix_disabled', {}, locale);
81
+ message.push('');
82
+ }
83
+
74
84
  if (hiveEnabled) {
75
85
  addLine(message, 'telegram.help_hive_enabled', {}, locale);
76
86
  addLine(message, 'telegram.help_hive_usage', {}, locale);
@@ -0,0 +1,96 @@
1
+ // CLI configuration module for the telegram-bot command.
2
+ // Extracted from telegram-bot.mjs so the option surface can be reviewed, tested
3
+ // and reused without loading the bot itself.
4
+ //
5
+ // Defaults read the environment through getenv() when createYargsConfig() runs,
6
+ // so callers must load .env/.lenv configuration before calling it.
7
+
8
+ import { getenv } from './cli-arguments.lib.mjs';
9
+
10
+ export const createYargsConfig = yargsInstance =>
11
+ yargsInstance
12
+ .usage('Usage: hive-telegram-bot [options]')
13
+ .option('configuration', {
14
+ type: 'string',
15
+ description: 'LINO configuration string for environment variables',
16
+ alias: 'c',
17
+ default: getenv('TELEGRAM_CONFIGURATION', ''),
18
+ })
19
+ .option('token', {
20
+ type: 'string',
21
+ description: 'Telegram bot token from @BotFather',
22
+ alias: 't',
23
+ default: getenv('TELEGRAM_BOT_TOKEN', ''),
24
+ })
25
+ .option('allowedChats', {
26
+ type: 'string',
27
+ description: 'Allowed chat IDs in lino notation, e.g., "(\n 123456789\n 987654321\n)"',
28
+ alias: 'allowed-chats',
29
+ default: getenv('TELEGRAM_ALLOWED_CHATS', ''),
30
+ })
31
+ .option('allowedTopics', {
32
+ type: 'string',
33
+ description: 'Allowed topic IDs in Links Notation format "chatId topicId" pairs',
34
+ alias: 'allowed-topics',
35
+ default: getenv('TELEGRAM_ALLOWED_TOPICS', ''),
36
+ })
37
+ .option('solveOverrides', {
38
+ type: 'string',
39
+ description: 'Override options for /solve command in lino notation, e.g., "(\n --auto-continue\n --attach-logs\n)"',
40
+ alias: 'solve-overrides',
41
+ default: getenv('TELEGRAM_SOLVE_OVERRIDES', ''),
42
+ })
43
+ .option('hiveOverrides', {
44
+ type: 'string',
45
+ description: 'Override options for /hive command in lino notation, e.g., "(\n --verbose\n --all-issues\n)"',
46
+ alias: 'hive-overrides',
47
+ default: getenv('TELEGRAM_HIVE_OVERRIDES', ''),
48
+ })
49
+ .option('solve', {
50
+ type: 'boolean',
51
+ description: 'Enable /solve command (use --no-solve to disable)',
52
+ default: getenv('TELEGRAM_SOLVE', 'true') !== 'false',
53
+ })
54
+ .option('hive', {
55
+ type: 'boolean',
56
+ description: 'Enable /hive command (use --no-hive to disable)',
57
+ default: getenv('TELEGRAM_HIVE', 'true') !== 'false',
58
+ })
59
+ .option('task', {
60
+ type: 'boolean',
61
+ description: 'Enable /task and /split commands (use --no-task to disable)',
62
+ default: getenv('TELEGRAM_TASK', 'true') !== 'false',
63
+ })
64
+ .option('fix', {
65
+ type: 'boolean',
66
+ description: 'Enable /fix command (use --no-fix to disable)',
67
+ default: getenv('TELEGRAM_FIX', 'true') !== 'false',
68
+ })
69
+ .option('auth', {
70
+ type: 'boolean',
71
+ description: 'Enable experimental private /auth command for allowlisted chat owners (use --no-auth to disable)',
72
+ default: getenv('TELEGRAM_AUTH', 'true') !== 'false',
73
+ })
74
+ .option('dryRun', {
75
+ type: 'boolean',
76
+ description: 'Validate configuration and options without starting the bot',
77
+ alias: 'dry-run',
78
+ default: false,
79
+ })
80
+ .option('verbose', {
81
+ type: 'boolean',
82
+ description: 'Enable verbose logging for debugging',
83
+ alias: 'v',
84
+ default: getenv('TELEGRAM_BOT_VERBOSE', 'false') === 'true',
85
+ })
86
+ .option('autoStartScreenWatchMessage', { type: 'boolean', description: 'Experimental: auto-start separate /terminal_watch messages for public /solve sessions', alias: 'auto-start-screen-watch-message', default: getenv('TELEGRAM_AUTO_START_SCREEN_WATCH_MESSAGE', getenv('TELEGRAM_AUTO_WATCH_MESSAGE', 'false')) === 'true' })
87
+ // Issue #594: bot-owner toggle for --show-limits virtual option in /solve and /hive.
88
+ .option('showLimits', { type: 'boolean', description: 'Experimental: allow /solve and /hive callers to use --show-limits to embed Claude/Codex usage at start, end, and delta in the completion message', alias: 'show-limits', default: getenv('TELEGRAM_SHOW_LIMITS', 'true') !== 'false' })
89
+ .option('isolation', { type: 'string', description: "Isolation backend (screen/tmux/docker). Defaults to 'docker' so Telegram-bot work sessions run in Docker isolation; pass --isolation '' (or set TELEGRAM_ISOLATION='') to disable.", default: getenv('TELEGRAM_ISOLATION', 'docker') })
90
+ .help('h')
91
+ .alias('h', 'help')
92
+ .parserConfiguration({
93
+ 'boolean-negation': true,
94
+ 'strip-dashed': true, // Remove dashed keys from argv to simplify validation
95
+ })
96
+ .strict(); // Enable strict mode to reject unknown options (consistent with solve.mjs and hive.mjs)