@link-assistant/hive-mind 1.69.5 → 1.69.7
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 +12 -0
- package/package.json +1 -1
- package/src/locales/en.lino +2 -2
- package/src/locales/hi.lino +2 -2
- package/src/locales/ru.lino +2 -2
- package/src/locales/zh.lino +2 -2
- package/src/telegram-bot.mjs +1 -1
- package/src/telegram-start-stop-command.lib.mjs +269 -79
- package/src/telegram-terminal-watch-command.lib.mjs +32 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.69.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 2ea2bb7: Extend Telegram `/stop` to accept a GitHub issue or pull-request URL (passed as the argument or contained in the replied-to message). The bot looks the URL up in the in-memory solve queue and either cancels the queued item or forwards CTRL+C via `$ --stop <UUID>` to the running isolated session. The UUID flow from #524 and the chat-level pause flow from #1081 are preserved.
|
|
8
|
+
|
|
9
|
+
## 1.69.6
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- c2c51fa: Allow Telegram `/terminal_watch` and `/watch` to be used by the user who started a tracked session while preserving chat-owner access and private-repository DM routing.
|
|
14
|
+
|
|
3
15
|
## 1.69.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/locales/en.lino
CHANGED
|
@@ -175,10 +175,10 @@ en
|
|
|
175
175
|
telegram.help_stop_start "*/stop* / */start* - Stop or resume accepting new tasks (owner only)"
|
|
176
176
|
telegram.help_stop_uuid "*/stop* `<uuid>` - Send CTRL+C to an isolated solve/hive session (owner only). Also works as a reply to a message containing the UUID."
|
|
177
177
|
telegram.help_log "*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`"
|
|
178
|
-
telegram.help_terminal_watch "*/terminal\\_watch* - Live-update an isolation session log (owner
|
|
178
|
+
telegram.help_terminal_watch "*/terminal\\_watch* (alias: */watch*) - Live-update an isolation session log (chat owner or session requester). Usage: `/terminal_watch <uuid>`, `/watch <uuid>`, or reply with either command"
|
|
179
179
|
telegram.help_notifications "🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards."
|
|
180
180
|
telegram.help_isolation_mode "🔒 *Isolation Mode:* `{{isolationBackend}}` (experimental)"
|
|
181
|
-
telegram.help_group_note "⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /terminal\\_watch, /subscribe and /unsubscribe work in private and group chats."
|
|
181
|
+
telegram.help_group_note "⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /terminal\\_watch, /watch, /subscribe and /unsubscribe work in private and group chats."
|
|
182
182
|
telegram.help_common_options "🔧 *Common Options:*"
|
|
183
183
|
telegram.help_model_option "• `--model <model>` or `-m` - {{modelDescription}}"
|
|
184
184
|
telegram.help_base_branch_option "• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)"
|
package/src/locales/hi.lino
CHANGED
|
@@ -175,10 +175,10 @@ hi
|
|
|
175
175
|
telegram.help_stop_start "*/stop* / */start* - नए tasks स्वीकार करना रोकें या फिर शुरू करें (केवल owner)"
|
|
176
176
|
telegram.help_stop_uuid "*/stop* `<uuid>` - isolated solve/hive session को CTRL+C भेजें (केवल owner)। UUID वाले message पर reply के रूप में भी काम करता है।"
|
|
177
177
|
telegram.help_log "*/log* - isolated session log लाएँ (केवल owner)। उपयोग: `/log <uuid>` या `/log` से reply करें"
|
|
178
|
-
telegram.help_terminal_watch "*/terminal\\_watch* - isolated session log को live-update करें (
|
|
178
|
+
telegram.help_terminal_watch "*/terminal\\_watch* (alias: */watch*) - isolated session log को live-update करें (chat owner या session requester)। उपयोग: `/terminal_watch <uuid>`, `/watch <uuid>`, या इनमें से किसी command से reply करें"
|
|
179
179
|
telegram.help_notifications "🔔 *Session Notifications:* completion notifications automatic हैं; private DM forwards के लिए /subscribe उपयोग करें।"
|
|
180
180
|
telegram.help_isolation_mode "🔒 *Isolation Mode:* `{{isolationBackend}}` (experimental)"
|
|
181
|
-
telegram.help_group_note "⚠️ *नोट:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop और /start commands केवल group chats में काम करती हैं। /terminal\\_watch, /subscribe और /unsubscribe private और group chats में काम करती हैं।"
|
|
181
|
+
telegram.help_group_note "⚠️ *नोट:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop और /start commands केवल group chats में काम करती हैं। /terminal\\_watch, /watch, /subscribe और /unsubscribe private और group chats में काम करती हैं।"
|
|
182
182
|
telegram.help_common_options "🔧 *Common Options:*"
|
|
183
183
|
telegram.help_model_option "• `--model <model>` या `-m` - {{modelDescription}}"
|
|
184
184
|
telegram.help_base_branch_option "• `--base-branch <branch>` या `-b` - PR के लिए target branch (default: repo default branch)"
|
package/src/locales/ru.lino
CHANGED
|
@@ -175,10 +175,10 @@ ru
|
|
|
175
175
|
telegram.help_stop_start "*/stop* / */start* - Остановить или возобновить прием новых задач (только владелец)"
|
|
176
176
|
telegram.help_stop_uuid "*/stop* `<uuid>` - Отправить CTRL+C в изолированный сеанс solve/hive (только владелец). Также работает ответом на сообщение с UUID."
|
|
177
177
|
telegram.help_log "*/log* - Получить лог изолированного сеанса (только владелец). Использование: `/log <uuid>` или ответ `/log`"
|
|
178
|
-
telegram.help_terminal_watch "*/terminal\\_watch* - Обновлять лог изолированного сеанса вживую (
|
|
178
|
+
telegram.help_terminal_watch "*/terminal\\_watch* (псевдоним: */watch*) - Обновлять лог изолированного сеанса вживую (владелец чата или пользователь, запустивший сеанс). Использование: `/terminal_watch <uuid>`, `/watch <uuid>` или ответ одной из этих команд"
|
|
179
179
|
telegram.help_notifications "🔔 *Уведомления о сеансах:* Уведомления о завершении автоматические; используйте /subscribe для личных пересылок."
|
|
180
180
|
telegram.help_isolation_mode "🔒 *Режим изоляции:* `{{isolationBackend}}` (экспериментально)"
|
|
181
|
-
telegram.help_group_note "⚠️ *Замечание:* команды /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop и /start работают только в групповых чатах. /terminal\\_watch, /subscribe и /unsubscribe работают в личных и групповых чатах."
|
|
181
|
+
telegram.help_group_note "⚠️ *Замечание:* команды /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop и /start работают только в групповых чатах. /terminal\\_watch, /watch, /subscribe и /unsubscribe работают в личных и групповых чатах."
|
|
182
182
|
telegram.help_common_options "🔧 *Общие опции:*"
|
|
183
183
|
telegram.help_model_option "• `--model <model>` или `-m` - {{modelDescription}}"
|
|
184
184
|
telegram.help_base_branch_option "• `--base-branch <branch>` или `-b` - Целевая ветка для PR (по умолчанию ветка репозитория)"
|
package/src/locales/zh.lino
CHANGED
|
@@ -175,10 +175,10 @@ zh
|
|
|
175
175
|
telegram.help_stop_start "*/stop* / */start* - 停止或恢复接收新任务(仅所有者)"
|
|
176
176
|
telegram.help_stop_uuid "*/stop* `<uuid>` - 向隔离的 solve/hive 会话发送 CTRL+C(仅所有者)。也可回复包含 UUID 的消息。"
|
|
177
177
|
telegram.help_log "*/log* - 获取隔离会话日志(仅所有者)。用法:`/log <uuid>` 或回复 `/log`"
|
|
178
|
-
telegram.help_terminal_watch "*/terminal\\_watch
|
|
178
|
+
telegram.help_terminal_watch "*/terminal\\_watch*(别名:*/watch*)- 实时更新隔离会话日志(聊天所有者或会话请求者)。用法:`/terminal_watch <uuid>`、`/watch <uuid>`,或用任一命令回复"
|
|
179
179
|
telegram.help_notifications "🔔 *会话通知:* 完成通知会自动发送;使用 /subscribe 获取私聊转发。"
|
|
180
180
|
telegram.help_isolation_mode "🔒 *隔离模式:* `{{isolationBackend}}`(实验性)"
|
|
181
|
-
telegram.help_group_note "⚠️ *注意:* /solve、/do、/continue、/claude、/codex、/opencode、/agent、/gemini、/qwen、/task、/split、/hive、/solve\\_queue、/limits、/version、/accept\\_invites、/merge、/stop 和 /start 仅在群聊中有效。/terminal\\_watch、/subscribe 和 /unsubscribe 在私聊和群聊中有效。"
|
|
181
|
+
telegram.help_group_note "⚠️ *注意:* /solve、/do、/continue、/claude、/codex、/opencode、/agent、/gemini、/qwen、/task、/split、/hive、/solve\\_queue、/limits、/version、/accept\\_invites、/merge、/stop 和 /start 仅在群聊中有效。/terminal\\_watch、/watch、/subscribe 和 /unsubscribe 在私聊和群聊中有效。"
|
|
182
182
|
telegram.help_common_options "🔧 *常用选项:*"
|
|
183
183
|
telegram.help_model_option "• `--model <model>` 或 `-m` - {{modelDescription}}"
|
|
184
184
|
telegram.help_base_branch_option "• `--base-branch <branch>` 或 `-b` - PR 目标分支(默认:仓库默认分支)"
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -1057,7 +1057,7 @@ const { registerTopCommand } = await import('./telegram-top-command.lib.mjs');
|
|
|
1057
1057
|
const { registerStartStopCommands } = await import('./telegram-start-stop-command.lib.mjs');
|
|
1058
1058
|
const { registerLogCommand } = await import('./telegram-log-command.lib.mjs');
|
|
1059
1059
|
registerTopCommand(bot, sharedCommandOpts);
|
|
1060
|
-
registerStartStopCommands(bot, sharedCommandOpts);
|
|
1060
|
+
registerStartStopCommands(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
1061
1061
|
await registerLogCommand(bot, sharedCommandOpts);
|
|
1062
1062
|
await registerTerminalWatchCommand(bot, sharedCommandOpts);
|
|
1063
1063
|
|
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
* - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
|
|
14
14
|
* matching isolated solve/hive session via `$ --stop <UUID>` from
|
|
15
15
|
* link-foundation/start (issue #524).
|
|
16
|
+
* - `/stop <issue-or-pr-url>` (or reply to a message that contains one) looks
|
|
17
|
+
* the URL up in the in-memory solve queue and either cancels the queued
|
|
18
|
+
* item or forwards CTRL+C to the running isolated session (issue #1780).
|
|
16
19
|
*
|
|
17
20
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
18
21
|
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
22
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
19
23
|
* @see https://github.com/link-foundation/start/issues/112
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
26
|
import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
|
|
27
|
+
import { parseGitHubUrl } from './github.lib.mjs';
|
|
28
|
+
import { cleanNonPrintableChars } from './telegram-markdown.lib.mjs';
|
|
29
|
+
|
|
30
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
31
|
|
|
24
32
|
// Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
|
|
25
33
|
const stoppedChats = new Map();
|
|
@@ -117,6 +125,66 @@ export function extractStopSessionId(text, repliedTo) {
|
|
|
117
125
|
return { sessionId: null, source: null };
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Walk arbitrary text and return the first GitHub issue or pull-request URL
|
|
130
|
+
* found, or null. Tolerates multiple URLs (returns the first issue/pull URL
|
|
131
|
+
* in source order). Uses the same `parseGitHubUrl` validator as the rest of
|
|
132
|
+
* the bot so the result is always a normalized URL string.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
function findFirstIssueOrPullUrl(text) {
|
|
138
|
+
if (!text || typeof text !== 'string') return null;
|
|
139
|
+
const cleaned = cleanNonPrintableChars(text);
|
|
140
|
+
for (const word of cleaned.split(/\s+/)) {
|
|
141
|
+
if (!word) continue;
|
|
142
|
+
const parsed = parseGitHubUrl(word);
|
|
143
|
+
if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
|
|
144
|
+
return parsed.normalized;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract the target of a `/stop` invocation. Returns the most specific
|
|
152
|
+
* target found among the four possible sources, in this priority order:
|
|
153
|
+
*
|
|
154
|
+
* 1. UUID in the `/stop` argument (kind='uuid', source='argument')
|
|
155
|
+
* 2. UUID in the replied-to message (kind='uuid', source='reply')
|
|
156
|
+
* 3. Issue/PR URL in the `/stop` argument (kind='url', source='argument')
|
|
157
|
+
* 4. Issue/PR URL in the replied-to text (kind='url', source='reply')
|
|
158
|
+
*
|
|
159
|
+
* UUIDs win over URLs because UUIDs are globally unique whereas a single
|
|
160
|
+
* issue URL can map to several in-flight requests if the user enqueued the
|
|
161
|
+
* same issue twice. Argument wins over reply because the argument is the
|
|
162
|
+
* more deliberate signal (the user explicitly typed it).
|
|
163
|
+
*
|
|
164
|
+
* @param {string} text - Raw `/stop ...` command text
|
|
165
|
+
* @param {Object|null|undefined} repliedTo - Telegram message object being replied to
|
|
166
|
+
* @returns {{ kind: 'uuid'|'url'|null, value: string|null, source: 'argument'|'reply'|null }}
|
|
167
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1780
|
|
168
|
+
*/
|
|
169
|
+
export function extractStopTarget(text, repliedTo) {
|
|
170
|
+
const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
|
|
171
|
+
const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
|
|
172
|
+
|
|
173
|
+
const argUuid = extractSessionIdFromText(argText);
|
|
174
|
+
if (argUuid) return { kind: 'uuid', value: argUuid, source: 'argument' };
|
|
175
|
+
|
|
176
|
+
const replyUuid = extractSessionIdFromText(replyText);
|
|
177
|
+
if (replyUuid) return { kind: 'uuid', value: replyUuid, source: 'reply' };
|
|
178
|
+
|
|
179
|
+
const argUrl = findFirstIssueOrPullUrl(argText);
|
|
180
|
+
if (argUrl) return { kind: 'url', value: argUrl, source: 'argument' };
|
|
181
|
+
|
|
182
|
+
const replyUrl = findFirstIssueOrPullUrl(replyText);
|
|
183
|
+
if (replyUrl) return { kind: 'url', value: replyUrl, source: 'reply' };
|
|
184
|
+
|
|
185
|
+
return { kind: null, value: null, source: null };
|
|
186
|
+
}
|
|
187
|
+
|
|
120
188
|
/**
|
|
121
189
|
* Registers the /start and /stop command handlers with the bot
|
|
122
190
|
* @param {Object} bot - The Telegraf bot instance
|
|
@@ -129,9 +197,13 @@ export function extractStopSessionId(text, repliedTo) {
|
|
|
129
197
|
* @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
|
|
130
198
|
* @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
|
|
131
199
|
* @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
|
|
200
|
+
* @param {Function} [options.getSolveQueue] - Returns the in-memory SolveQueue (for `/stop <url>`).
|
|
201
|
+
* When omitted, the URL flow degrades gracefully to a "no queue available"
|
|
202
|
+
* message so unit tests for non-URL paths don't need to construct a queue.
|
|
203
|
+
* See https://github.com/link-assistant/hive-mind/issues/1780.
|
|
132
204
|
*/
|
|
133
205
|
export function registerStartStopCommands(bot, options) {
|
|
134
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
206
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, getSolveQueue } = options;
|
|
135
207
|
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
136
208
|
|
|
137
209
|
/**
|
|
@@ -180,12 +252,146 @@ export function registerStartStopCommands(bot, options) {
|
|
|
180
252
|
return { valid: true, chatId };
|
|
181
253
|
}
|
|
182
254
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Owner-only auth check for the /stop UUID and /stop URL flows. Mirrors the
|
|
257
|
+
* /log auth model: in private DMs the user is implicitly the owner; in
|
|
258
|
+
* groups they must be the chat creator. Replies with the appropriate error
|
|
259
|
+
* directly when auth fails.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} ctx - Telegraf context
|
|
262
|
+
* @param {string} label - Short human-readable label for the variant ('UUID', 'URL')
|
|
263
|
+
* @returns {Promise<boolean>} true when authorized
|
|
264
|
+
*/
|
|
265
|
+
async function authorizeTargetedStop(ctx, label) {
|
|
266
|
+
const message = ctx.message;
|
|
267
|
+
const chatId = ctx.chat?.id;
|
|
268
|
+
const chatType = ctx.chat?.type;
|
|
269
|
+
if (chatType === 'private') return true;
|
|
270
|
+
if (!isGroupChat(ctx)) {
|
|
271
|
+
await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
if (!isChatAuthorized(chatId)) {
|
|
275
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
276
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
277
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
283
|
+
if (!member || member.status !== 'creator') {
|
|
284
|
+
VERBOSE && console.log(`[VERBOSE] /stop <${label}> ignored: user is not chat owner`);
|
|
285
|
+
await ctx.reply(`❌ /stop <${label}> is only available to the chat owner.`, { reply_to_message_id: message.message_id });
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error(`[ERROR] /stop <${label}>: getChatMember failed:`, error);
|
|
290
|
+
await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Forward CTRL+C to a running isolated session via `$ --stop <uuid>`.
|
|
298
|
+
* Posts an ack reply, edits it with the result. Used by both the
|
|
299
|
+
* `/stop <UUID>` path (issue #524) and the `/stop <url>` path when the
|
|
300
|
+
* matched queue item is already executing in an isolated session
|
|
301
|
+
* (issue #1780).
|
|
302
|
+
*
|
|
303
|
+
* @param {Object} ctx - Telegraf context
|
|
304
|
+
* @param {string} sessionId - UUID of the session to stop
|
|
305
|
+
*/
|
|
306
|
+
async function runStopIsolatedSessionFlow(ctx, sessionId) {
|
|
307
|
+
const message = ctx.message;
|
|
308
|
+
const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
|
|
309
|
+
parse_mode: 'Markdown',
|
|
310
|
+
reply_to_message_id: message.message_id,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
let result;
|
|
314
|
+
try {
|
|
315
|
+
result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.error('[ERROR] /stop: stopIsolatedSession threw:', error);
|
|
318
|
+
result = { success: false, output: '', error: error?.message || String(error) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const trimmedOutput = (result.output || '').toString().trim();
|
|
322
|
+
const trimmedError = (result.error || '').toString().trim();
|
|
323
|
+
const lines = [];
|
|
324
|
+
if (result.success) {
|
|
325
|
+
lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
|
|
326
|
+
lines.push('');
|
|
327
|
+
lines.push('The session should terminate shortly.');
|
|
328
|
+
if (trimmedOutput) {
|
|
329
|
+
lines.push('');
|
|
330
|
+
lines.push('```');
|
|
331
|
+
lines.push(trimmedOutput.slice(0, 1000));
|
|
332
|
+
lines.push('```');
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
|
|
336
|
+
if (trimmedError) {
|
|
337
|
+
lines.push('');
|
|
338
|
+
lines.push('```');
|
|
339
|
+
lines.push(trimmedError.slice(0, 1000));
|
|
340
|
+
lines.push('```');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
|
|
346
|
+
} catch (error) {
|
|
347
|
+
console.error('[ERROR] /stop: editMessageText failed, falling back to reply:', error);
|
|
348
|
+
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve a `/stop <url>` request against the in-memory solve queue.
|
|
354
|
+
* Returns an action descriptor that the dispatcher executes.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} url - Normalized GitHub issue or PR URL
|
|
357
|
+
* @returns {{ action: 'no-queue'|'not-found'|'cancel-queued'|'stop-running'|'running-not-isolated', item?: Object, sessionId?: string|null, tool?: string|null }}
|
|
358
|
+
*/
|
|
359
|
+
function resolveQueueLookupForUrl(url) {
|
|
360
|
+
if (typeof getSolveQueue !== 'function') {
|
|
361
|
+
return { action: 'no-queue' };
|
|
362
|
+
}
|
|
363
|
+
const queue = getSolveQueue({ verbose: VERBOSE });
|
|
364
|
+
const item = queue?.findByUrl?.(url);
|
|
365
|
+
if (!item) return { action: 'not-found' };
|
|
366
|
+
|
|
367
|
+
// Queued items have a defined .id and live in one of the per-tool queues.
|
|
368
|
+
// The cancel(id) call walks every per-tool queue and returns true on hit.
|
|
369
|
+
const cancelled = queue.cancel(item.id);
|
|
370
|
+
if (cancelled) {
|
|
371
|
+
return { action: 'cancel-queued', item, tool: item.tool || null };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Not in a per-tool queue → must be in `processing`. If it was started
|
|
375
|
+
// via an isolation backend, item.sessionName is the start-command UUID
|
|
376
|
+
// and we can forward CTRL+C to it. Non-isolated runs have a screen name
|
|
377
|
+
// that is not UUID-shaped — we can't safely interrupt those from here.
|
|
378
|
+
const sessionId = item.sessionName && UUID_RE.test(item.sessionName) ? item.sessionName : null;
|
|
379
|
+
if (sessionId) {
|
|
380
|
+
return { action: 'stop-running', item, sessionId, tool: item.tool || null };
|
|
381
|
+
}
|
|
382
|
+
return { action: 'running-not-isolated', item, tool: item.tool || null };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// /stop command. Three modes (checked in this order, before any reply
|
|
386
|
+
// rejection so the queue-card-reply ergonomics from issue #1780 work):
|
|
387
|
+
// 1. `/stop <UUID>` or reply with UUID — forward CTRL+C via
|
|
388
|
+
// `$ --stop <UUID>` (issue #524).
|
|
389
|
+
// 2. `/stop <issue-or-pr-url>` or reply containing that URL — look up
|
|
390
|
+
// the matching solve queue item; cancel it if queued, forward
|
|
391
|
+
// CTRL+C if running with isolation (issue #1780).
|
|
392
|
+
// 3. bare `/stop` (optionally with a free-text reason) — pause new task
|
|
187
393
|
// acceptance for the chat (issue #1081).
|
|
188
|
-
// Only accessible by chat owner (creator) in
|
|
394
|
+
// Only accessible by chat owner (creator) in modes 1, 2 (in groups).
|
|
189
395
|
bot.command('stop', async ctx => {
|
|
190
396
|
VERBOSE && console.log('[VERBOSE] /stop command received');
|
|
191
397
|
if (isOldMessage(ctx)) {
|
|
@@ -193,92 +399,76 @@ export function registerStartStopCommands(bot, options) {
|
|
|
193
399
|
return;
|
|
194
400
|
}
|
|
195
401
|
|
|
196
|
-
// Detect UUID
|
|
197
|
-
// chat-level stop, because
|
|
198
|
-
//
|
|
402
|
+
// Detect UUID/URL targets BEFORE the forwarded/reply rejection used by
|
|
403
|
+
// the chat-level stop, because both targeted modes are intentionally
|
|
404
|
+
// delivered as replies (issues #524, #1780).
|
|
199
405
|
const message = ctx.message;
|
|
200
406
|
const repliedTo = message?.reply_to_message || null;
|
|
201
|
-
const
|
|
407
|
+
const target = extractStopTarget(message?.text || '', repliedTo);
|
|
408
|
+
|
|
409
|
+
if (target.kind === 'uuid') {
|
|
410
|
+
const sessionId = target.value;
|
|
411
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${target.source})`);
|
|
412
|
+
const ok = await authorizeTargetedStop(ctx, 'UUID');
|
|
413
|
+
if (!ok) return;
|
|
414
|
+
await runStopIsolatedSessionFlow(ctx, sessionId);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
202
417
|
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
const member = await ctx.telegram.getChatMember(chatId, ctx.from.id);
|
|
223
|
-
if (!member || member.status !== 'creator') {
|
|
224
|
-
VERBOSE && console.log('[VERBOSE] /stop <UUID> ignored: user is not chat owner');
|
|
225
|
-
await ctx.reply('❌ /stop <UUID> is only available to the chat owner.', { reply_to_message_id: message.message_id });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.error('[ERROR] /stop <UUID>: getChatMember failed:', error);
|
|
230
|
-
await ctx.reply('❌ Failed to verify permissions for /stop.', { reply_to_message_id: message.message_id });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
418
|
+
if (target.kind === 'url') {
|
|
419
|
+
const url = target.value;
|
|
420
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected URL ${url} (source=${target.source})`);
|
|
421
|
+
const ok = await authorizeTargetedStop(ctx, 'URL');
|
|
422
|
+
if (!ok) return;
|
|
423
|
+
|
|
424
|
+
const lookup = resolveQueueLookupForUrl(url);
|
|
425
|
+
VERBOSE && console.log(`[VERBOSE] /stop: queue lookup for ${url} → ${lookup.action}`);
|
|
426
|
+
|
|
427
|
+
if (lookup.action === 'no-queue') {
|
|
428
|
+
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.`, {
|
|
429
|
+
parse_mode: 'Markdown',
|
|
430
|
+
reply_to_message_id: message.message_id,
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
233
433
|
}
|
|
234
434
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
try {
|
|
242
|
-
result = await stopIsolatedSessionImpl(sessionId, VERBOSE);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
console.error('[ERROR] /stop <UUID>: stopIsolatedSession threw:', error);
|
|
245
|
-
result = { success: false, output: '', error: error?.message || String(error) };
|
|
435
|
+
if (lookup.action === 'not-found') {
|
|
436
|
+
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).`, {
|
|
437
|
+
parse_mode: 'Markdown',
|
|
438
|
+
reply_to_message_id: message.message_id,
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
246
441
|
}
|
|
247
442
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
lines.push('');
|
|
257
|
-
lines.push('```');
|
|
258
|
-
lines.push(trimmedOutput.slice(0, 1000));
|
|
259
|
-
lines.push('```');
|
|
260
|
-
}
|
|
261
|
-
} else {
|
|
262
|
-
lines.push(`❌ Failed to stop session \`${sessionId}\`.`);
|
|
263
|
-
if (trimmedError) {
|
|
264
|
-
lines.push('');
|
|
265
|
-
lines.push('```');
|
|
266
|
-
lines.push(trimmedError.slice(0, 1000));
|
|
267
|
-
lines.push('```');
|
|
268
|
-
}
|
|
443
|
+
if (lookup.action === 'cancel-queued') {
|
|
444
|
+
VERBOSE && console.log(`[VERBOSE] /stop: cancelled queued item ${lookup.item?.id} for ${url}`);
|
|
445
|
+
const toolLabel = lookup.tool ? ` from \`${lookup.tool}\` queue` : '';
|
|
446
|
+
await ctx.reply(`🗑 Removed queued task for ${url}${toolLabel}.`, {
|
|
447
|
+
parse_mode: 'Markdown',
|
|
448
|
+
reply_to_message_id: message.message_id,
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
269
451
|
}
|
|
270
452
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
453
|
+
if (lookup.action === 'stop-running') {
|
|
454
|
+
VERBOSE && console.log(`[VERBOSE] /stop: forwarding CTRL+C to running session ${lookup.sessionId} for ${url}`);
|
|
455
|
+
await runStopIsolatedSessionFlow(ctx, lookup.sessionId);
|
|
456
|
+
return;
|
|
276
457
|
}
|
|
458
|
+
|
|
459
|
+
// running-not-isolated: a started, non-isolated screen session. We
|
|
460
|
+
// could shell out to `screen -X -S <name> stuff $'\003'`, but that's
|
|
461
|
+
// brittle and out of scope for #1780. Tell the user how to recover.
|
|
462
|
+
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\`.`, {
|
|
463
|
+
parse_mode: 'Markdown',
|
|
464
|
+
reply_to_message_id: message.message_id,
|
|
465
|
+
});
|
|
277
466
|
return;
|
|
278
467
|
}
|
|
279
468
|
|
|
280
|
-
// No UUID — fall through to the chat-level pause flow. That flow
|
|
281
|
-
// forwards/replies on purpose (#1081) so a stray reply doesn't
|
|
469
|
+
// No UUID or URL — fall through to the chat-level pause flow. That flow
|
|
470
|
+
// rejects forwards/replies on purpose (#1081) so a stray reply doesn't
|
|
471
|
+
// pause the chat.
|
|
282
472
|
if (isForwardedOrReply(ctx)) {
|
|
283
473
|
VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
|
|
284
474
|
return;
|
|
@@ -17,7 +17,7 @@ const activeWatches = new Map();
|
|
|
17
17
|
|
|
18
18
|
function splitCommandArgs(text) {
|
|
19
19
|
const body = String(text || '')
|
|
20
|
-
.replace(/^\/terminal_watch(?:@\w+)?\b/i, '')
|
|
20
|
+
.replace(/^\/(?:terminal_watch|watch)(?:@\w+)?\b/i, '')
|
|
21
21
|
.trim();
|
|
22
22
|
return body.match(/"[^"]*"|'[^']*'|\S+/g)?.map(token => token.replace(/^(['"])(.*)\1$/, '$2')) || [];
|
|
23
23
|
}
|
|
@@ -171,6 +171,12 @@ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbos
|
|
|
171
171
|
return null;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
export function isTerminalWatchSessionRequester({ sessionInfo = null, userId = null } = {}) {
|
|
175
|
+
if (userId === null || userId === undefined) return false;
|
|
176
|
+
if (sessionInfo?.requesterUserId === null || sessionInfo?.requesterUserId === undefined) return false;
|
|
177
|
+
return String(sessionInfo.requesterUserId) === String(userId);
|
|
178
|
+
}
|
|
179
|
+
|
|
174
180
|
// Note: /terminal_watch never uploads the full session log itself (issue #1720).
|
|
175
181
|
// Use /log <uuid> if you want the log file delivered as a document.
|
|
176
182
|
function getDisplayedTerminalSnapshot(logText, options) {
|
|
@@ -229,7 +235,7 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
|
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
function buildUsage() {
|
|
232
|
-
return 'Usage:\n• `/terminal_watch <UUID
|
|
238
|
+
return 'Usage:\n• `/terminal_watch <UUID>` (alias: `/watch <UUID>`)\n• Reply to a session message with `/terminal_watch` or `/watch`\n\nOptions: `--size 120x25`, `--width 120`, `--height 25`, `--interval-ms 2500`, `--max-chars 3400`';
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
async function createWatchMessage({ ctx, targetChatId, replyToMessageId, text }) {
|
|
@@ -302,12 +308,14 @@ export async function startAutoTerminalWatchForSession({ bot, ctx, sessionId, se
|
|
|
302
308
|
|
|
303
309
|
export async function registerTerminalWatchCommand(bot, options) {
|
|
304
310
|
const { VERBOSE = false, isOldMessage, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
305
|
-
const runner = await import('./isolation-runner.lib.mjs');
|
|
311
|
+
const runner = !options.querySessionStatus || !options.isTerminalSessionStatus ? await import('./isolation-runner.lib.mjs') : null;
|
|
312
|
+
const querySessionStatus = options.querySessionStatus || runner.querySessionStatus;
|
|
313
|
+
const isTerminalSessionStatus = options.isTerminalSessionStatus || runner.isTerminalSessionStatus;
|
|
306
314
|
const getTrackedSessionInfo = options.getTrackedSessionInfo || (await import('./session-monitor.lib.mjs')).getTrackedSessionInfo;
|
|
307
315
|
const detectRepositoryVisibility = options.detectRepositoryVisibility || (await import('./github.lib.mjs')).detectRepositoryVisibility;
|
|
308
316
|
const parseGitHubUrl = options.parseGitHubUrl || (await import('./github.lib.mjs')).parseGitHubUrl;
|
|
309
317
|
|
|
310
|
-
|
|
318
|
+
const handleTerminalWatchCommand = async ctx => {
|
|
311
319
|
VERBOSE && console.log('[VERBOSE] /terminal_watch command received');
|
|
312
320
|
if (isOldMessage && isOldMessage(ctx)) return;
|
|
313
321
|
|
|
@@ -327,17 +335,23 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
327
335
|
return;
|
|
328
336
|
}
|
|
329
337
|
|
|
338
|
+
const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
|
|
339
|
+
|
|
330
340
|
if (chat.type !== 'private') {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
341
|
+
if (isTerminalWatchSessionRequester({ sessionInfo, userId: ctx.from?.id })) {
|
|
342
|
+
VERBOSE && console.log(`[VERBOSE] /terminal_watch allowed for session requester ${ctx.from?.id} on ${sessionId}`);
|
|
343
|
+
} else {
|
|
344
|
+
try {
|
|
345
|
+
const member = await ctx.telegram.getChatMember(chat.id, ctx.from.id);
|
|
346
|
+
if (!member || member.status !== 'creator') {
|
|
347
|
+
await ctx.reply('❌ /terminal_watch is only available to the chat owner or the user who started this session.', { reply_to_message_id: message.message_id });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.error('[ERROR] /terminal_watch: getChatMember failed:', error);
|
|
352
|
+
await ctx.reply('❌ Failed to verify permissions for /terminal_watch.', { reply_to_message_id: message.message_id });
|
|
335
353
|
return;
|
|
336
354
|
}
|
|
337
|
-
} catch (error) {
|
|
338
|
-
console.error('[ERROR] /terminal_watch: getChatMember failed:', error);
|
|
339
|
-
await ctx.reply('❌ Failed to verify permissions for /terminal_watch.', { reply_to_message_id: message.message_id });
|
|
340
|
-
return;
|
|
341
355
|
}
|
|
342
356
|
}
|
|
343
357
|
|
|
@@ -349,7 +363,7 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
349
363
|
|
|
350
364
|
let statusResult;
|
|
351
365
|
try {
|
|
352
|
-
statusResult = await
|
|
366
|
+
statusResult = await querySessionStatus(sessionId, VERBOSE);
|
|
353
367
|
} catch (error) {
|
|
354
368
|
console.error('[ERROR] /terminal_watch: querySessionStatus failed:', error);
|
|
355
369
|
await ctx.reply(`❌ Failed to query session status: ${error.message || String(error)}`, { reply_to_message_id: message.message_id });
|
|
@@ -361,7 +375,6 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
361
375
|
return;
|
|
362
376
|
}
|
|
363
377
|
|
|
364
|
-
const sessionInfo = getTrackedSessionInfo ? getTrackedSessionInfo(sessionId) : null;
|
|
365
378
|
const { repoVisibility, repoDescription } = await resolveTerminalWatchRepository({ sessionInfo, statusResult, parseGitHubUrl, detectRepositoryVisibility });
|
|
366
379
|
const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType: chat.type });
|
|
367
380
|
if (decision.destination === 'reject') {
|
|
@@ -378,13 +391,16 @@ export async function registerTerminalWatchCommand(bot, options) {
|
|
|
378
391
|
}
|
|
379
392
|
|
|
380
393
|
try {
|
|
381
|
-
await startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult, sessionInfo, decision, logPath, watchOptions: parsedArgs.options, querySessionStatus
|
|
394
|
+
await startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult, sessionInfo, decision, logPath, watchOptions: parsedArgs.options, querySessionStatus, isTerminalSessionStatus, repoDescription, verbose: VERBOSE });
|
|
382
395
|
} catch (error) {
|
|
383
396
|
console.error('[ERROR] /terminal_watch: failed to start watch:', error);
|
|
384
397
|
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 start terminal watch: ${error.message || String(error)}`;
|
|
385
398
|
await ctx.reply(`❌ ${friendly}`, { reply_to_message_id: message.message_id });
|
|
386
399
|
}
|
|
387
|
-
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
bot.command('terminal_watch', handleTerminalWatchCommand);
|
|
403
|
+
bot.command('watch', handleTerminalWatchCommand);
|
|
388
404
|
}
|
|
389
405
|
|
|
390
406
|
export const __INTERNAL_FOR_TESTS__ = {
|