@link-assistant/hive-mind 1.74.6 → 1.74.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.74.8
4
+
5
+ ### Patch Changes
6
+
7
+ - c132ce0: Fix `/stop <issue-or-pr-url>` so it can stop tasks that started immediately
8
+ (empty queue) or were already dispatched to a detached isolation session. The
9
+ URL lookup now also consults the session-monitor registry and forwards CTRL+C
10
+ to the tracked start-command UUID, so all three stop modes (issue URL, PR URL,
11
+ and session UUID) work end-to-end (#1871).
12
+
13
+ ## 1.74.7
14
+
15
+ ### Patch Changes
16
+
17
+ - 8ea7110: Document the issue #1858 case study and add an experimental private Telegram
18
+ `/auth` command for allowlisted chat owners to check or start GitHub, Claude,
19
+ and Codex auth flows.
20
+
3
21
  ## 1.74.6
4
22
 
5
23
  ### Patch Changes
package/README.hi.md CHANGED
@@ -559,6 +559,7 @@ Shows:
559
559
  - ✅ **Screen सत्र**: कमांड डिटैच्ड screen सत्रों में चलते हैं
560
560
  - ✅ **Live Terminal Watch**: `/terminal_watch` और opt-in auto-start live session logs दिखाते हैं
561
561
  - ✅ **चैट प्रतिबंध**: अनुमत चैट ID की वैकल्पिक सफेद सूची
562
+ - ✅ **Private Auth Check**: allowlisted chat owners के लिए experimental `/auth --status <gh|claude|codex>` और `/auth --login <gh|claude|codex>`
562
563
  - ✅ **डायग्नोस्टिक टूल**: चैट ID और कॉन्फ़िगरेशन जानकारी प्राप्त करें
563
564
 
564
565
  #### Live Terminal Watch
@@ -577,6 +578,8 @@ sessions के लिए अपने आप एक अलग live terminal wat
577
578
 
578
579
  - केवल उन ग्रुप चैट में काम करता है जहाँ बॉट एडमिन है
579
580
  - `TELEGRAM_ALLOWED_CHATS` के माध्यम से वैकल्पिक चैट ID प्रतिबंध
581
+ - private `/auth` तब disabled रहता है जब `TELEGRAM_ALLOWED_CHATS` set नहीं है,
582
+ और इसे केवल listed chats के owners इस्तेमाल कर सकते हैं
580
583
  - बॉट चलाने वाले सिस्टम उपयोगकर्ता के रूप में कमांड चलते हैं
581
584
  - उचित प्रमाणीकरण सुनिश्चित करें (`gh auth login`, `claude-profiles`)
582
585
 
package/README.md CHANGED
@@ -580,6 +580,7 @@ Shows:
580
580
  - ✅ **Screen Sessions**: Commands run in detached screen sessions
581
581
  - ✅ **Live Terminal Watch**: `/terminal_watch` and opt-in auto-start show live session logs
582
582
  - ✅ **Chat Restrictions**: Optional whitelist of allowed chat IDs
583
+ - ✅ **Private Auth Check**: Experimental `/auth --status <gh|claude|codex>` and `/auth --login <gh|claude|codex>` for owners of allowlisted chats
583
584
  - ✅ **Diagnostic Tools**: Get chat ID and configuration info
584
585
 
585
586
  #### Live Terminal Watch
@@ -597,6 +598,8 @@ When enabled with `--auto-start-screen-watch-message`, the bot automatically sta
597
598
 
598
599
  - Only works in group chats where the bot is admin
599
600
  - Optional chat ID restrictions via `TELEGRAM_ALLOWED_CHATS`
601
+ - Private `/auth` is disabled unless `TELEGRAM_ALLOWED_CHATS` is set and only
602
+ owners of listed chats can use it
600
603
  - Commands run as the system user running the bot
601
604
  - Ensure proper authentication (`gh auth login`, `claude-profiles`)
602
605
 
package/README.ru.md CHANGED
@@ -561,6 +561,7 @@ Shows:
561
561
  - ✅ **Screen-сессии**: команды запускаются в отсоединённых screen-сессиях
562
562
  - ✅ **Live Terminal Watch**: `/terminal_watch` и opt-in auto-start показывают live session logs
563
563
  - ✅ **Ограничения по чатам**: опциональный белый список разрешённых ID чатов
564
+ - ✅ **Приватная проверка auth**: экспериментальные `/auth --status <gh|claude|codex>` и `/auth --login <gh|claude|codex>` для владельцев разрешённых чатов
564
565
  - ✅ **Диагностические инструменты**: получение ID чата и информации о конфигурации
565
566
 
566
567
  #### Live Terminal Watch
@@ -579,6 +580,8 @@ Shows:
579
580
 
580
581
  - Работает только в групповых чатах, где бот является администратором
581
582
  - Опциональное ограничение по ID чата через `TELEGRAM_ALLOWED_CHATS`
583
+ - Приватная `/auth` отключена, если `TELEGRAM_ALLOWED_CHATS` не задан, и
584
+ доступна только владельцам перечисленных чатов
582
585
  - Команды выполняются от имени системного пользователя, запустившего бота
583
586
  - Убедитесь в наличии надлежащей аутентификации (`gh auth login`, `claude-profiles`)
584
587
 
package/README.zh.md CHANGED
@@ -555,6 +555,7 @@ Shows:
555
555
  - ✅ **Screen 会话**:命令在后台 Screen 会话中运行
556
556
  - ✅ **Live Terminal Watch**:`/terminal_watch` 和 opt-in auto-start 显示 live session logs
557
557
  - ✅ **聊天限制**:可选配置允许的聊天 ID 白名单
558
+ - ✅ **私聊 Auth 检查**:为白名单聊天所有者提供实验性的 `/auth --status <gh|claude|codex>` 和 `/auth --login <gh|claude|codex>`
558
559
  - ✅ **诊断工具**:获取聊天 ID 和配置信息
559
560
 
560
561
  #### Live Terminal Watch
@@ -573,6 +574,7 @@ Shows:
573
574
 
574
575
  - 仅在机器人为管理员的群聊中有效
575
576
  - 可通过 `TELEGRAM_ALLOWED_CHATS` 配置可选的聊天 ID 限制
577
+ - 如果未设置 `TELEGRAM_ALLOWED_CHATS`,私聊 `/auth` 会被禁用,且只有所列聊天的所有者可以使用
576
578
  - 命令以运行机器人的系统用户身份执行
577
579
  - 请确保已完成正确的身份验证(`gh auth login`、`claude-profiles`)
578
580
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.74.6",
3
+ "version": "1.74.8",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -150,7 +150,11 @@ function isMessageAlreadyUpdatedError(error) {
150
150
  }
151
151
 
152
152
  function normalizeSessionUrl(url) {
153
- return url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase();
153
+ // Strip the fragment first, then any trailing slashes, so URLs that carry a
154
+ // fragment after a trailing slash (e.g. `.../issues/18/#comment`) normalize to
155
+ // the same value as the bare `.../issues/18`. Doing it in the other order
156
+ // would leave a dangling trailing slash. (Issue #1871.)
157
+ return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
154
158
  }
155
159
 
156
160
  function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
@@ -488,6 +492,73 @@ export function hasActiveSessionForUrl(url, verbose = false) {
488
492
  return { isActive: false, sessionName: null };
489
493
  }
490
494
 
495
+ const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
496
+
497
+ /**
498
+ * Issue #1871: Find a tracked, still-running session for a GitHub issue/PR URL
499
+ * and report whether it can be stopped by forwarding CTRL+C to the
500
+ * start-command session UUID.
501
+ *
502
+ * The `/stop <url>` Telegram flow originally consulted only the in-memory solve
503
+ * queue. But a `/solve` or `/codex` that starts immediately (queue empty)
504
+ * dispatches straight to a detached isolation session and is removed from the
505
+ * queue's `processing` Map the moment it is launched. From that point on the
506
+ * session-monitor's in-memory registry is the only place that still knows the
507
+ * URL → start-command-UUID mapping, so `/stop <url>` reported "no task found"
508
+ * even though the task was clearly running. This helper exposes that registry
509
+ * so the stop flow can recover the UUID and interrupt the session.
510
+ *
511
+ * A session is stoppable when it was launched with an isolation backend and its
512
+ * start-command UUID is UUID-shaped (the value `$ --stop <uuid>` expects). Plain
513
+ * non-isolation screen sessions are reported but marked `stoppable: false`
514
+ * because `$ --stop` cannot interrupt them.
515
+ *
516
+ * @param {string} url - GitHub issue or PR URL (any normalization)
517
+ * @param {boolean} verbose - Whether to log verbose output
518
+ * @returns {{ sessionName: string, sessionId: string|null, sessionInfo: Object,
519
+ * isolationBackend: string|null, stoppable: boolean }|null} Match or null
520
+ */
521
+ export function findStoppableSessionByUrl(url, verbose = false) {
522
+ if (!url) return null;
523
+
524
+ const normalizedUrl = normalizeSessionUrl(url);
525
+
526
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
527
+ if (!sessionInfo.url || normalizeSessionUrl(sessionInfo.url) !== normalizedUrl) {
528
+ continue;
529
+ }
530
+ // Issue #1586: skip expired non-isolation sessions — they are no longer running.
531
+ if (!sessionInfo.isolationBackend && !isNonIsolationSessionActive(sessionName, sessionInfo, verbose)) {
532
+ continue;
533
+ }
534
+
535
+ // The UUID `$ --stop` expects is the start-command session id. For
536
+ // isolation sessions it is tracked either as sessionInfo.sessionId or as
537
+ // the (UUID-shaped) session key itself.
538
+ const candidateId = sessionInfo.sessionId || sessionName;
539
+ const sessionId = SESSION_UUID_RE.test(candidateId) ? candidateId : null;
540
+ const stoppable = Boolean(sessionInfo.isolationBackend && sessionId);
541
+
542
+ if (verbose) {
543
+ const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation';
544
+ console.log(`[VERBOSE] findStoppableSessionByUrl: matched ${sessionName} for ${url} (${mode}, stoppable=${stoppable})`);
545
+ }
546
+
547
+ return {
548
+ sessionName,
549
+ sessionId,
550
+ sessionInfo,
551
+ isolationBackend: sessionInfo.isolationBackend || null,
552
+ stoppable,
553
+ };
554
+ }
555
+
556
+ if (verbose) {
557
+ console.log(`[VERBOSE] findStoppableSessionByUrl: no tracked session for ${url}`);
558
+ }
559
+ return null;
560
+ }
561
+
491
562
  /**
492
563
  * Async active-session check for command handlers.
493
564
  *
@@ -0,0 +1,298 @@
1
+ import { spawn } from 'child_process';
2
+ import { parseCommandArgs } from './telegram-solve-command.lib.mjs';
3
+
4
+ export const AUTH_PROVIDERS = Object.freeze(['gh', 'claude', 'codex']);
5
+
6
+ const AUTH_PROVIDER_SET = new Set(AUTH_PROVIDERS);
7
+ const AUTH_USAGE = 'Usage: /auth --status <gh|claude|codex> or /auth --login <gh|claude|codex>';
8
+ // eslint-disable-next-line no-control-regex
9
+ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
10
+ const TOKEN_RE = /\b(?:gh[opsu]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g;
11
+ const TOKEN_FIELD_RE = /\b(token|access_token|refresh_token|api[_-]?key|authorization)\s*[:=]\s*["']?[^"'\s,}]+/gi;
12
+
13
+ function trimOutput(text, max = 3500) {
14
+ const value = String(text || '').trim();
15
+ if (value.length <= max) return value;
16
+ return value.slice(0, max) + `\n... truncated ${value.length - max} characters`;
17
+ }
18
+
19
+ function escapeCodeFence(text) {
20
+ return String(text || '').replace(/```/g, '` ` `');
21
+ }
22
+
23
+ function normalizeProvider(provider) {
24
+ return String(provider || '')
25
+ .trim()
26
+ .toLowerCase();
27
+ }
28
+
29
+ function readActionValue(arg) {
30
+ if (arg === '--status') return { action: 'status', provider: null, consumesNext: true };
31
+ if (arg === '--login') return { action: 'login', provider: null, consumesNext: true };
32
+ if (arg.startsWith('--status=')) return { action: 'status', provider: arg.slice('--status='.length), consumesNext: false };
33
+ if (arg.startsWith('--login=')) return { action: 'login', provider: arg.slice('--login='.length), consumesNext: false };
34
+ return null;
35
+ }
36
+
37
+ export function parseAuthRequest(text) {
38
+ const args = parseCommandArgs(text || '');
39
+ let action = null;
40
+ let provider = null;
41
+
42
+ for (let i = 0; i < args.length; i++) {
43
+ const parsed = readActionValue(args[i]);
44
+ if (!parsed) {
45
+ return { action: null, provider: null, error: `Unsupported /auth argument: ${args[i]}\n\n${AUTH_USAGE}` };
46
+ }
47
+ if (action) {
48
+ return { action: null, provider: null, error: `Use exactly one of --status or --login.\n\n${AUTH_USAGE}` };
49
+ }
50
+ action = parsed.action;
51
+ provider = normalizeProvider(parsed.provider);
52
+ if (parsed.consumesNext) {
53
+ const next = args[i + 1];
54
+ if (!next || next.startsWith('--')) {
55
+ return { action: null, provider: null, error: AUTH_USAGE };
56
+ }
57
+ provider = normalizeProvider(next);
58
+ i++;
59
+ }
60
+ }
61
+
62
+ if (!action || !provider) {
63
+ return { action: null, provider: null, error: AUTH_USAGE };
64
+ }
65
+ if (!AUTH_PROVIDER_SET.has(provider)) {
66
+ return { action, provider: null, error: `Unsupported auth provider: ${provider}\n\n${AUTH_USAGE}` };
67
+ }
68
+
69
+ return { action, provider, error: null };
70
+ }
71
+
72
+ export function buildAuthCommand(action, provider) {
73
+ if (action === 'status') {
74
+ if (provider === 'gh') return { command: 'gh', args: ['auth', 'status', '--hostname', 'github.com'] };
75
+ if (provider === 'claude') return { command: 'claude', args: ['auth', 'status'] };
76
+ if (provider === 'codex') return { command: 'codex', args: ['login', 'status'] };
77
+ }
78
+ if (action === 'login') {
79
+ if (provider === 'gh') return { command: 'gh', args: ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'] };
80
+ if (provider === 'claude') return { command: 'claude', args: ['auth', 'login', '--claudeai'] };
81
+ if (provider === 'codex') return { command: 'codex', args: ['login', '--device-auth'] };
82
+ }
83
+ throw new Error(`Unsupported auth command: ${action} ${provider}`);
84
+ }
85
+
86
+ export function redactAuthOutput(output) {
87
+ return String(output || '')
88
+ .replace(ANSI_RE, '')
89
+ .replace(TOKEN_RE, '[REDACTED_TOKEN]')
90
+ .replace(TOKEN_FIELD_RE, (_, name) => `${name}: [REDACTED_TOKEN]`);
91
+ }
92
+
93
+ function collectAuthOutput(result) {
94
+ return redactAuthOutput([result?.stdout, result?.stderr].filter(Boolean).join('\n'));
95
+ }
96
+
97
+ export function extractAuthStartDetails(output) {
98
+ const text = redactAuthOutput(output);
99
+ const urls = [...new Set([...text.matchAll(/https?:\/\/[^\s<>)"']+/g)].map(match => match[0].replace(/[.,;:!?]+$/, '')))];
100
+
101
+ const codePatterns = [/\bone-time code\s*[:=]\s*([A-Z0-9][A-Z0-9-]{3,})/i, /\b(?:user code|verification code|code)\s*[:=]\s*([A-Z0-9][A-Z0-9-]{3,})/i, /\b([A-Z0-9]{4,}-[A-Z0-9-]{4,})\b/];
102
+ let code = null;
103
+ for (const pattern of codePatterns) {
104
+ const match = text.match(pattern);
105
+ if (match) {
106
+ code = match[1].toUpperCase();
107
+ break;
108
+ }
109
+ }
110
+
111
+ return { urls, code };
112
+ }
113
+
114
+ export function formatAuthStatusMessage(provider, result) {
115
+ const code = result?.code;
116
+ const ok = code === 0;
117
+ const output = trimOutput(collectAuthOutput(result)) || '(no output)';
118
+ return `${ok ? 'OK' : 'ERROR'} *${provider} auth status*\n\nExit code: ${code ?? 'unknown'}\n\n\`\`\`\n${escapeCodeFence(output)}\n\`\`\``;
119
+ }
120
+
121
+ export function formatAuthLoginMessage(provider, result) {
122
+ const output = collectAuthOutput(result);
123
+ const details = extractAuthStartDetails(output);
124
+ const lines = [`*${provider} auth login started*`, '', 'The local login command was cancelled locally after capturing the browser step, so this bot command did not replace existing credentials.'];
125
+
126
+ if (details.urls.length > 0) {
127
+ lines.push('', 'Open this URL:');
128
+ for (const url of details.urls) lines.push(url);
129
+ }
130
+ if (details.code) {
131
+ lines.push('', `Code: \`${details.code}\``);
132
+ }
133
+ if (details.urls.length === 0 && !details.code) {
134
+ const shownOutput = trimOutput(output) || '(no output captured)';
135
+ lines.push('', 'Captured output:', '', '```', escapeCodeFence(shownOutput), '```');
136
+ }
137
+ if (result?.cancelled) {
138
+ lines.push('', 'Status: cancelled locally after capture.');
139
+ } else if (typeof result?.code === 'number') {
140
+ lines.push('', `Status: login command exited with code ${result.code}.`);
141
+ }
142
+ lines.push('', 'Continuation by replying with a provider code is not automated yet; this is the first experimental CLI-backed /auth path.');
143
+ return lines.join('\n');
144
+ }
145
+
146
+ export const resolveAllowedAuthChatIds = allowedChats => {
147
+ if (!allowedChats) return [];
148
+ const raw = typeof allowedChats === 'function' ? allowedChats() : allowedChats;
149
+ if (!Array.isArray(raw)) return [];
150
+ return raw.map(value => String(value)).filter(Boolean);
151
+ };
152
+
153
+ export async function isAuthOperator({ telegram, userId, allowedChatIds }) {
154
+ if (!telegram || !userId || !allowedChatIds || allowedChatIds.length === 0) {
155
+ return false;
156
+ }
157
+ for (const chatId of allowedChatIds) {
158
+ if (String(chatId) === String(userId)) return true;
159
+ try {
160
+ const member = await telegram.getChatMember(chatId, userId);
161
+ if (member?.status === 'creator') return true;
162
+ } catch {
163
+ // Try the next configured chat. The bot may no longer be a member.
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+
169
+ export function runAuthCommand(command, args, options = {}) {
170
+ const { mode = 'status', loginCaptureMs = 15000, outputLimit = 20000, env = process.env } = options;
171
+ return new Promise(resolve => {
172
+ const child = spawn(command, args, {
173
+ stdio: ['ignore', 'pipe', 'pipe'],
174
+ env,
175
+ });
176
+ let stdout = '';
177
+ let stderr = '';
178
+ let settled = false;
179
+ let captureTimer = null;
180
+
181
+ const settle = result => {
182
+ if (settled) return;
183
+ settled = true;
184
+ if (captureTimer) clearTimeout(captureTimer);
185
+ resolve({
186
+ stdout: stdout.slice(0, outputLimit),
187
+ stderr: stderr.slice(0, outputLimit),
188
+ ...result,
189
+ });
190
+ };
191
+
192
+ const maybeCancelLogin = () => {
193
+ if (mode !== 'login' || settled) return;
194
+ const details = extractAuthStartDetails(`${stdout}\n${stderr}`);
195
+ if (details.urls.length === 0 && !details.code) return;
196
+ child.kill('SIGTERM');
197
+ settle({ code: null, signal: 'SIGTERM', cancelled: true });
198
+ };
199
+
200
+ child.stdout.on('data', data => {
201
+ stdout += data.toString();
202
+ maybeCancelLogin();
203
+ });
204
+ child.stderr.on('data', data => {
205
+ stderr += data.toString();
206
+ maybeCancelLogin();
207
+ });
208
+ child.on('error', error => {
209
+ settle({ code: null, error: error.message });
210
+ });
211
+ child.on('close', (code, signal) => {
212
+ settle({ code, signal, cancelled: false });
213
+ });
214
+
215
+ if (mode === 'login') {
216
+ captureTimer = setTimeout(() => {
217
+ child.kill('SIGTERM');
218
+ settle({ code: null, signal: 'SIGTERM', cancelled: true });
219
+ }, loginCaptureMs);
220
+ }
221
+ });
222
+ }
223
+
224
+ export function registerAuthCommand(bot, options = {}) {
225
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, allowedChats, authEnabled = true } = options;
226
+ const execute = options.runCommand || runAuthCommand;
227
+ const reply = options.safeReply || ((ctx, text, replyOptions) => ctx.reply(text, replyOptions));
228
+
229
+ async function handleAuthCommand(ctx) {
230
+ VERBOSE && console.log('[VERBOSE] /auth command received');
231
+
232
+ if (isOldMessage && isOldMessage(ctx)) {
233
+ VERBOSE && console.log('[VERBOSE] /auth ignored: old message');
234
+ return;
235
+ }
236
+ if (isForwardedOrReply && isForwardedOrReply(ctx)) {
237
+ VERBOSE && console.log('[VERBOSE] /auth ignored: forwarded or reply');
238
+ return;
239
+ }
240
+ if (!authEnabled) {
241
+ await reply(ctx, 'The /auth command is disabled on this bot instance.', { reply_to_message_id: ctx.message?.message_id });
242
+ return;
243
+ }
244
+ if (!ctx.chat || !ctx.from || !ctx.message) return;
245
+ if (ctx.chat.type !== 'private') {
246
+ await reply(ctx, 'The /auth command is only available in private messages.', { reply_to_message_id: ctx.message.message_id });
247
+ return;
248
+ }
249
+
250
+ const allowedChatIds = resolveAllowedAuthChatIds(allowedChats);
251
+ if (allowedChatIds.length === 0) {
252
+ await reply(ctx, 'The /auth command is disabled because TELEGRAM_ALLOWED_CHATS is not configured.', { reply_to_message_id: ctx.message.message_id });
253
+ return;
254
+ }
255
+
256
+ const authorized = await isAuthOperator({ telegram: ctx.telegram, userId: ctx.from.id, allowedChatIds });
257
+ if (!authorized) {
258
+ VERBOSE && console.log(`[VERBOSE] /auth denied: user ${ctx.from.id} is not creator of any allowed chat`);
259
+ await reply(ctx, 'The /auth command is only available to owners of allowlisted chats.', { reply_to_message_id: ctx.message.message_id });
260
+ return;
261
+ }
262
+
263
+ const request = parseAuthRequest(ctx.message.text || '');
264
+ if (request.error) {
265
+ await reply(ctx, request.error, { reply_to_message_id: ctx.message.message_id });
266
+ return;
267
+ }
268
+
269
+ const { command, args } = buildAuthCommand(request.action, request.provider);
270
+ let result;
271
+ try {
272
+ result = await execute(command, args, { mode: request.action, provider: request.provider });
273
+ } catch (error) {
274
+ await reply(ctx, `Failed to run ${request.provider} auth ${request.action}: ${error.message || String(error)}`, { reply_to_message_id: ctx.message.message_id });
275
+ return;
276
+ }
277
+
278
+ const message = request.action === 'status' ? formatAuthStatusMessage(request.provider, result) : formatAuthLoginMessage(request.provider, result);
279
+ await reply(ctx, message, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
280
+ }
281
+
282
+ bot.command('auth', handleAuthCommand);
283
+ return { handleAuthCommand };
284
+ }
285
+
286
+ export default {
287
+ AUTH_PROVIDERS,
288
+ buildAuthCommand,
289
+ extractAuthStartDetails,
290
+ formatAuthLoginMessage,
291
+ formatAuthStatusMessage,
292
+ isAuthOperator,
293
+ parseAuthRequest,
294
+ redactAuthOutput,
295
+ registerAuthCommand,
296
+ resolveAllowedAuthChatIds,
297
+ runAuthCommand,
298
+ };
@@ -43,7 +43,7 @@ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized
43
43
  const { installTelegramFormattingFallback, isTelegramFormattingError, isTelegramMessageTooLongError, safeEditMessageText, safeReply, TELEGRAM_TEXT_LIMIT } = await import('./telegram-safe-reply.lib.mjs');
44
44
  const { registerTerminalWatchCommand, startAutoTerminalWatchForSession } = await import('./telegram-terminal-watch-command.lib.mjs');
45
45
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
46
- const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync } = await import('./session-monitor.lib.mjs');
46
+ const { trackSession, startSessionMonitoring, hasActiveSessionForUrlAsync, findStoppableSessionByUrl } = await import('./session-monitor.lib.mjs');
47
47
  const { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = await import('./work-session-formatting.lib.mjs');
48
48
  const { buildTelegramHelpMessage, buildTelegramInfoBlock, buildSolveQueuedMessage } = await import('./telegram-ui-messages.lib.mjs');
49
49
 
@@ -100,6 +100,11 @@ const config = yargs(hideBin(process.argv))
100
100
  description: 'Enable /task and /split commands (use --no-task to disable)',
101
101
  default: getenv('TELEGRAM_TASK', 'true') !== 'false',
102
102
  })
103
+ .option('auth', {
104
+ type: 'boolean',
105
+ description: 'Enable experimental private /auth command for allowlisted chat owners (use --no-auth to disable)',
106
+ default: getenv('TELEGRAM_AUTH', 'true') !== 'false',
107
+ })
103
108
  .option('dryRun', {
104
109
  type: 'boolean',
105
110
  description: 'Validate configuration and options without starting the bot',
@@ -163,6 +168,7 @@ const hiveOverrides = resolvedHiveOverrides
163
168
  const solveEnabled = config.solve;
164
169
  const hiveEnabled = config.hive;
165
170
  const taskEnabled = config.task;
171
+ const authEnabled = config.auth;
166
172
  // Isolation mode (experimental): uses `$` from start-command with specified backend
167
173
  const ISOLATION_BACKEND = (config.isolation || getenv('TELEGRAM_ISOLATION', '')).trim().toLowerCase();
168
174
  let isolationRunner = null;
@@ -283,7 +289,7 @@ if (config.dryRun) {
283
289
  if (allowedTopics && allowedTopics.length > 0) {
284
290
  console.log(' Allowed topics:', lino.formatLinks(allowedTopics));
285
291
  }
286
- console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled });
292
+ console.log(' Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled, auth: authEnabled });
287
293
  if (solveOverrides.length > 0) {
288
294
  console.log(' Solve overrides:', lino.format(solveOverrides));
289
295
  }
@@ -606,6 +612,8 @@ const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.m
606
612
  registerSubscribeCommands(bot, sharedCommandOpts);
607
613
  const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
608
614
  const { handleTaskCommand, TASK_COMMAND_NAMES } = registerTaskCommands(bot, { ...sharedCommandOpts, taskEnabled, safeReply, executeAndUpdateMessage, resolveLocale: resolveLocaleFromTelegramCtx });
615
+ const { registerAuthCommand } = await import('./telegram-auth-command.lib.mjs');
616
+ const { handleAuthCommand } = registerAuthCommand(bot, { ...sharedCommandOpts, allowedChats, authEnabled, safeReply });
609
617
 
610
618
  // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
611
619
  async function handleSolveCommand(ctx) {
@@ -1060,7 +1068,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
1060
1068
  const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
1061
1069
  const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
1062
1070
  registerTopCommand(bot, sharedCommandOpts);
1063
- registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
1071
+ registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue, findRunningSessionByUrl: (url, verbose) => findStoppableSessionByUrl(url, verbose) });
1064
1072
  await registerLogCommand(bot, sharedCommandOpts);
1065
1073
  await registerTerminalWatchCommand(bot, sharedCommandOpts);
1066
1074
 
@@ -1170,7 +1178,7 @@ bot.on('message', async (ctx, next) => {
1170
1178
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1171
1179
  const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
1172
1180
  // /queue is the short alias for /solve_queue (issue #1837)
1173
- const handlers = { ...solveHandlers, ...taskHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
1181
+ const handlers = { ...solveHandlers, ...taskHandlers, auth: handleAuthCommand, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand, queue: handleSolveQueueCommand };
1174
1182
 
1175
1183
  const handler = handlers[extracted.command];
1176
1184
  if (!handler) return next();
@@ -1279,7 +1287,7 @@ if (allowedChats && allowedChats.length > 0) {
1279
1287
  if (allowedTopics && allowedTopics.length > 0) {
1280
1288
  console.log('Allowed topics (lino):', lino.formatLinks(allowedTopics));
1281
1289
  }
1282
- console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled });
1290
+ console.log('Commands enabled:', { solve: solveEnabled, hive: hiveEnabled, task: taskEnabled, auth: authEnabled });
1283
1291
  if (solveOverrides.length > 0) console.log('Solve overrides (lino):', lino.format(solveOverrides));
1284
1292
  if (hiveOverrides.length > 0) console.log('Hive overrides (lino):', lino.format(hiveOverrides));
1285
1293
  if (VERBOSE) {
@@ -16,10 +16,15 @@
16
16
  * - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
17
17
  * the URL up in the in-memory solve queue and either cancels the queued
18
18
  * item or forwards CTRL+C to the running isolated session (issue #1780).
19
+ * - `/stop <issue-or-pr-url>` also consults the session-monitor registry of
20
+ * running detached sessions, so it can interrupt a task that started
21
+ * immediately (queue empty) and was therefore never left in the queue's
22
+ * `processing` Map — the case shown in issue #1871's screenshots.
19
23
  *
20
24
  * @see https://github.com/link-assistant/hive-mind/issues/1081
21
25
  * @see https://github.com/link-assistant/hive-mind/issues/524
22
26
  * @see https://github.com/link-assistant/hive-mind/issues/1780
27
+ * @see https://github.com/link-assistant/hive-mind/issues/1871
23
28
  * @see https://github.com/link-foundation/start/issues/112
24
29
  */
25
30
 
@@ -257,6 +262,12 @@ export function isStopTargetRequester({ userId, queueItem = null, sessionInfo =
257
262
  * When omitted, the URL flow degrades gracefully to a "no queue available"
258
263
  * message so unit tests for non-URL paths don't need to construct a queue.
259
264
  * See https://github.com/link-assistant/hive-mind/issues/1780.
265
+ * @param {Function} [options.findRunningSessionByUrl] - Override for tests; looks
266
+ * the URL up in the session-monitor registry of running detached sessions so
267
+ * `/stop <url>` can interrupt tasks that started immediately (queue empty) and
268
+ * were therefore never left in the queue's `processing` Map. When omitted, the
269
+ * real `findStoppableSessionByUrl` from session-monitor is lazy-imported.
270
+ * See https://github.com/link-assistant/hive-mind/issues/1871.
260
271
  */
261
272
  export function registerStartStopCommands(bot, options) {
262
273
  const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
@@ -274,6 +285,26 @@ export function registerStartStopCommands(bot, options) {
274
285
  return mod.getTrackedSessionInfo(sessionId);
275
286
  }
276
287
 
288
+ // Issue #1871: look a URL up in the session-monitor registry of running
289
+ // detached sessions. A /solve or /codex that started immediately (queue
290
+ // empty) is dispatched straight to an isolation session and removed from the
291
+ // queue's `processing` Map, so the queue lookup alone reports "no task found"
292
+ // for a task that is clearly running. The session monitor still knows the
293
+ // URL → start-command-UUID mapping, which lets /stop <url> recover and
294
+ // interrupt the session. Test stubs can inject findRunningSessionByUrl.
295
+ async function lookupRunningSessionByUrl(url) {
296
+ try {
297
+ if (typeof options.findRunningSessionByUrl === 'function') {
298
+ return await options.findRunningSessionByUrl(url, VERBOSE);
299
+ }
300
+ const mod = await import('./session-monitor.lib.mjs');
301
+ return mod.findStoppableSessionByUrl(url, VERBOSE);
302
+ } catch (error) {
303
+ console.error('[ERROR] /stop: findStoppableSessionByUrl failed:', error);
304
+ return null;
305
+ }
306
+ }
307
+
277
308
  /**
278
309
  * Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
279
310
  * @param {Object} ctx - Telegraf context
@@ -529,18 +560,42 @@ export function registerStartStopCommands(bot, options) {
529
560
  const url = target.value;
530
561
  VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
531
562
 
532
- // Look up the queue item BEFORE auth so we can allow the original task
533
- // requester to cancel their own task in a group (#1783). The lookup
534
- // here does NOT mutate the queue actual cancel happens below in
535
- // resolveQueueLookupForUrl after auth has passed.
563
+ // Look up the queue item AND any running detached session BEFORE auth so
564
+ // we can allow the original task requester to cancel their own task in a
565
+ // group (#1783), regardless of whether the task is still queued or already
566
+ // dispatched to an isolation session (#1871). Neither lookup mutates
567
+ // state — actual cancel/stop happens below after auth has passed.
536
568
  const candidate = findQueueCandidateForUrl(url);
537
- const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null });
569
+ const runningSession = await lookupRunningSessionByUrl(url);
570
+ const ok = await authorizeTargetedStop(ctx, 'URL', { queueItem: candidate.item || null, sessionInfo: runningSession?.sessionInfo || null });
538
571
  if (!ok) return;
539
572
 
540
573
  const lookup = resolveQueueLookupForUrl(url);
541
574
  VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
542
575
 
576
+ // Issue #1871: when the queue has no record of the task (it started
577
+ // immediately and was dispatched to a detached session) but the session
578
+ // monitor still tracks a running isolated session for this URL, forward
579
+ // CTRL+C to its start-command UUID. This is the common case for tasks
580
+ // that begin executing right away with `--isolation screen`.
581
+ const queueHasTask = lookup.action === 'cancel-queued' || lookup.action === 'stop-running';
582
+ if (!queueHasTask && runningSession?.stoppable && runningSession.sessionId) {
583
+ VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to tracked session ${runningSession.sessionId} for ${url} (queue action=${lookup.action})`);
584
+ await runStopIsolatedSessionFlow(ctx, runningSession.sessionId);
585
+ return;
586
+ }
587
+
543
588
  if (lookup.action === 'no-queue') {
589
+ // No solve queue in this context. If the session monitor found a
590
+ // running-but-non-stoppable (non-isolation) session, say so; otherwise
591
+ // fall back to the UUID hint.
592
+ if (runningSession) {
593
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
594
+ parse_mode: 'Markdown',
595
+ reply_to_message_id: message.message_id,
596
+ });
597
+ return;
598
+ }
544
599
  await ctx.reply(`ℹ️ Cannot look up tasks by URL right now (the bot has no solve queue available in this context).\n\nIf you have the session UUID, you can use \`/stop <UUID>\` instead.`, {
545
600
  parse_mode: 'Markdown',
546
601
  reply_to_message_id: message.message_id,
@@ -549,6 +604,16 @@ export function registerStartStopCommands(bot, options) {
549
604
  }
550
605
 
551
606
  if (lookup.action === 'not-found') {
607
+ // The session monitor also had no stoppable session (otherwise we would
608
+ // have forwarded CTRL+C above). If it tracked a non-isolation session,
609
+ // explain why it can't be stopped; otherwise report not found.
610
+ if (runningSession) {
611
+ await ctx.reply(`⚠️ Found a running task for ${url}, but it was not started with an isolation backend, so \`/stop\` cannot forward CTRL+C to it.\n\nNext time you can run the command with \`--isolation screen\` to make this task interruptible via \`/stop\`.`, {
612
+ parse_mode: 'Markdown',
613
+ reply_to_message_id: message.message_id,
614
+ });
615
+ return;
616
+ }
552
617
  await ctx.reply(`ℹ️ No queued or running task found for ${url}.\n\nIf the task is running with \`--isolation screen\`, try \`/stop <UUID>\` (the UUID is shown in the bot's session-id message).`, {
553
618
  parse_mode: 'Markdown',
554
619
  reply_to_message_id: message.message_id,