@link-assistant/hive-mind 1.64.3 → 1.65.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.
- package/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/src/isolation-runner.lib.mjs +49 -0
- package/src/solve.auto-merge.lib.mjs +12 -2
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.watch.lib.mjs +46 -0
- package/src/telegram-bot.mjs +1 -0
- package/src/telegram-start-stop-command.lib.mjs +139 -3
- package/src/telegram-terminal-watch-command.lib.mjs +18 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.65.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 14fe57e: Prevent normal Docker release manifest jobs from downloading DinD digest artifacts.
|
|
8
|
+
- 74ce579: Reduce `/terminal_watch` Telegram edits by updating only when the displayed terminal snapshot changes and count only real terminal snapshot updates.
|
|
9
|
+
- 78ab6e2: Add `--auto-delete-branch-on-merge` option for the `solve` command. When set together with `--watch`, the branch is deleted from the remote after the pull request is merged; when set together with `--auto-merge`, the auto-merge call requests branch deletion as part of the merge. The option is opt-in (default `false`), enables full GitHub Flow automation, avoids temporary auto-restart cleanup, uses the GitHub REST API for watch-mode deletion, and treats "branch already gone" responses as success so it does not warn when GitHub's "Automatically delete head branches" repo setting beats us to it.
|
|
10
|
+
- 152de95: Add a Claude CLI streaming input case study with reproducible experiment scripts.
|
|
11
|
+
|
|
12
|
+
## 1.64.4
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- 20f5898: Add `/stop <UUID>` and reply-to-message-with-UUID modes to the Telegram bot (#524). Sending `/stop <uuid>` (or replying with `/stop` to a message containing a UUID) forwards CTRL+C to the matching isolated `/solve` or `/hive` session via `$ --stop <uuid>` from link-foundation/start (link-foundation/start#112), so individual screen/tmux/docker sessions can be cancelled from Telegram. Mirrors the existing `/log` and `/terminal_watch` UUID-resolution pattern. Bare `/stop` retains its existing chat-pause behaviour (#1081).
|
|
17
|
+
|
|
3
18
|
## 1.64.3
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -255,6 +255,55 @@ export async function querySessionStatus(sessionId, verbose = false) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Ask the `$` CLI to gracefully stop an isolated session by sending CTRL+C.
|
|
260
|
+
*
|
|
261
|
+
* Wraps `$ --stop <uuid>` from start-command (link-foundation/start#112).
|
|
262
|
+
* Works for any isolation backend (screen, tmux, docker, …) — `$` knows the
|
|
263
|
+
* backend it launched with and forwards the interrupt accordingly.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} sessionId - UUID of the session to stop
|
|
266
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
267
|
+
* @returns {Promise<{success: boolean, output: string, error: string|null}>}
|
|
268
|
+
*/
|
|
269
|
+
export async function stopIsolatedSession(sessionId, verbose = false) {
|
|
270
|
+
const binPath = await findStartCommandBinary();
|
|
271
|
+
if (!binPath) {
|
|
272
|
+
if (verbose) {
|
|
273
|
+
console.log('[VERBOSE] isolation-runner: Cannot stop session - $ binary not found');
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
success: false,
|
|
277
|
+
output: '',
|
|
278
|
+
error: '`$` (start-command) binary not found on PATH. Install link-foundation/start to use /stop <UUID>.',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const result = await $({ mirror: false })`${binPath} --stop ${sessionId}`;
|
|
284
|
+
const stdout = result.stdout?.toString() || '';
|
|
285
|
+
const stderr = result.stderr?.toString() || '';
|
|
286
|
+
if (verbose) {
|
|
287
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stdout: ${stdout.substring(0, 300)}`);
|
|
288
|
+
if (stderr) {
|
|
289
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stderr: ${stderr.substring(0, 300)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { success: true, output: stdout || stderr, error: null };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const stderr = error?.stderr?.toString?.() || '';
|
|
295
|
+
const stdout = error?.stdout?.toString?.() || '';
|
|
296
|
+
if (verbose) {
|
|
297
|
+
console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} failed: ${error.message}`);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
success: false,
|
|
301
|
+
output: stdout,
|
|
302
|
+
error: stderr.trim() || error?.message || String(error),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
258
307
|
/**
|
|
259
308
|
* Check if a screen session exists via `screen -ls`.
|
|
260
309
|
* Used as a fallback when `$ --status` fails to find or correctly track
|
|
@@ -67,6 +67,8 @@ const { maybeAttachWorkingSessionSummary } = resultsLib;
|
|
|
67
67
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
68
68
|
const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
|
|
69
69
|
|
|
70
|
+
const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
|
|
71
|
+
|
|
70
72
|
/**
|
|
71
73
|
* Main function: Watch and restart until PR becomes mergeable
|
|
72
74
|
* This implements --auto-restart-until-mergeable functionality
|
|
@@ -273,7 +275,11 @@ export const watchUntilMergeable = async params => {
|
|
|
273
275
|
if (isAutoMerge) {
|
|
274
276
|
// Attempt to merge the PR
|
|
275
277
|
await log(formatAligned('🔀', 'Auto-merging PR...', ''));
|
|
276
|
-
const
|
|
278
|
+
const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
|
|
279
|
+
if (deleteAfterMerge) {
|
|
280
|
+
await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
|
|
281
|
+
}
|
|
282
|
+
const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
|
|
277
283
|
|
|
278
284
|
if (mergeResult.success) {
|
|
279
285
|
await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
|
|
@@ -1045,7 +1051,11 @@ export const attemptAutoMerge = async params => {
|
|
|
1045
1051
|
await log(formatAligned('✅', 'PR is mergeable:', 'Attempting to merge...', 2));
|
|
1046
1052
|
|
|
1047
1053
|
// Attempt to merge
|
|
1048
|
-
const
|
|
1054
|
+
const deleteAfterMerge = shouldDeleteBranchAfterMerge(argv);
|
|
1055
|
+
if (deleteAfterMerge) {
|
|
1056
|
+
await log(formatAligned('', 'Branch cleanup:', 'will delete branch after successful merge', 2));
|
|
1057
|
+
}
|
|
1058
|
+
const mergeResult = await mergePullRequest(owner, repo, prNumber, { squash: argv.squash || false, deleteAfter: deleteAfterMerge }, argv.verbose);
|
|
1049
1059
|
|
|
1050
1060
|
if (mergeResult.success) {
|
|
1051
1061
|
await log(formatAligned('🎉', 'PR MERGED SUCCESSFULLY!', ''));
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -243,6 +243,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
243
243
|
description: 'Interval in seconds for checking feedback in watch mode (default: 60)',
|
|
244
244
|
default: 60,
|
|
245
245
|
},
|
|
246
|
+
'auto-delete-branch-on-merge': {
|
|
247
|
+
type: 'boolean',
|
|
248
|
+
description: 'Automatically delete the branch after the pull request is merged in --watch mode or by --auto-merge. Enables full GitHub Flow support (issue #401).',
|
|
249
|
+
default: false,
|
|
250
|
+
},
|
|
246
251
|
'min-disk-space': {
|
|
247
252
|
type: 'number',
|
|
248
253
|
description: 'Minimum required disk space in MB (default: 2048)',
|
package/src/solve.watch.lib.mjs
CHANGED
|
@@ -107,6 +107,52 @@ export const watchForFeedback = async params => {
|
|
|
107
107
|
await log('');
|
|
108
108
|
await log(formatAligned('🎉', 'PR MERGED!', 'Stopping watch mode'));
|
|
109
109
|
await log(formatAligned('', 'Pull request:', `#${prNumber} has been merged`, 2));
|
|
110
|
+
|
|
111
|
+
// Issue #401: If --auto-delete-branch-on-merge is enabled in --watch mode,
|
|
112
|
+
// delete the branch from the remote after the PR is merged. This enables
|
|
113
|
+
// full GitHub Flow automation. Only applies in --watch mode (not auto-restart),
|
|
114
|
+
// because auto-restart is for completing local work, not finalizing GitHub Flow.
|
|
115
|
+
const shouldAutoDeleteBranch = !isTemporaryWatch && argv.autoDeleteBranchOnMerge && branchName;
|
|
116
|
+
if (shouldAutoDeleteBranch) {
|
|
117
|
+
await log('');
|
|
118
|
+
await log(formatAligned('🗑️', 'AUTO-DELETE:', `Deleting branch ${branchName} after merge`));
|
|
119
|
+
try {
|
|
120
|
+
// Delete the branch from the remote via GitHub REST API.
|
|
121
|
+
// We use `gh api ... -X DELETE` rather than `git push --delete` so we don't
|
|
122
|
+
// require a configured local remote in tempDir at this point in the run.
|
|
123
|
+
const deleteBranchResult = await $`gh api repos/${owner}/${repo}/git/refs/heads/${branchName} -X DELETE`;
|
|
124
|
+
if (deleteBranchResult.code === 0) {
|
|
125
|
+
await log(formatAligned('✅', 'Branch deleted:', `${branchName}`, 2));
|
|
126
|
+
} else {
|
|
127
|
+
const stderrText = deleteBranchResult.stderr?.toString().trim() || 'Unknown error';
|
|
128
|
+
// 422 Reference does not exist -> branch was already deleted (e.g. GitHub's "Automatically delete head branches"
|
|
129
|
+
// setting raced ahead of us). Treat as success rather than warning.
|
|
130
|
+
if (/Reference does not exist|Not Found|422|404/i.test(stderrText)) {
|
|
131
|
+
await log(formatAligned('✅', 'Branch already removed:', `${branchName} (no action needed)`, 2));
|
|
132
|
+
} else {
|
|
133
|
+
await log(formatAligned('⚠️', 'Branch deletion failed:', stderrText, 2));
|
|
134
|
+
reportError(new Error(`Branch deletion returned non-zero exit code: ${stderrText}`), {
|
|
135
|
+
context: 'delete_branch_on_merge_non_zero',
|
|
136
|
+
owner,
|
|
137
|
+
repo,
|
|
138
|
+
branchName,
|
|
139
|
+
exitCode: deleteBranchResult.code,
|
|
140
|
+
operation: 'delete_remote_branch',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (deleteError) {
|
|
145
|
+
reportError(deleteError, {
|
|
146
|
+
context: 'delete_branch_on_merge',
|
|
147
|
+
owner,
|
|
148
|
+
repo,
|
|
149
|
+
branchName,
|
|
150
|
+
operation: 'delete_remote_branch',
|
|
151
|
+
});
|
|
152
|
+
await log(formatAligned('⚠️', 'Branch deletion error:', cleanErrorMessage(deleteError), 2));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
110
156
|
await log('');
|
|
111
157
|
break;
|
|
112
158
|
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -571,6 +571,7 @@ bot.command('help', async ctx => {
|
|
|
571
571
|
message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
|
|
572
572
|
message += '*/help* - Show this help message\n';
|
|
573
573
|
message += '*/stop* / */start* - Stop or resume accepting new tasks (owner only)\n';
|
|
574
|
+
message += '*/stop* `<uuid>` - Send CTRL+C to an isolated solve/hive session (owner only). Also works as a reply to a message containing the UUID.\n';
|
|
574
575
|
message += '*/log* - Fetch isolation session log (owner only). Usage: `/log <uuid>` or reply with `/log`\n';
|
|
575
576
|
message += '*/terminal\\_watch* - Live-update an isolation session log (owner only). Usage: `/terminal_watch <uuid>` or reply with `/terminal_watch`\n\n';
|
|
576
577
|
message += '🔔 *Session Notifications:* Completion notifications are automatic; use /subscribe for private DM forwards.\n';
|
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
* - Graceful stop: existing queue items continue to process
|
|
11
11
|
* - Read-only commands (/help, /limits, /version) remain available when stopped
|
|
12
12
|
* - Write commands (/solve, /hive) are rejected when stopped
|
|
13
|
+
* - `/stop <UUID>` or reply-to-message-with-UUID forwards CTRL+C to the
|
|
14
|
+
* matching isolated solve/hive session via `$ --stop <UUID>` from
|
|
15
|
+
* link-foundation/start (issue #524).
|
|
13
16
|
*
|
|
14
17
|
* @see https://github.com/link-assistant/hive-mind/issues/1081
|
|
18
|
+
* @see https://github.com/link-assistant/hive-mind/issues/524
|
|
19
|
+
* @see https://github.com/link-foundation/start/issues/112
|
|
15
20
|
*/
|
|
16
21
|
|
|
22
|
+
import { extractSessionIdFromText } from './telegram-log-command.lib.mjs';
|
|
23
|
+
|
|
17
24
|
// Store stopped chats: Map<chatId, { stoppedAt: Date, stoppedBy: { id, username, firstName }, reason?: string }>
|
|
18
25
|
const stoppedChats = new Map();
|
|
19
26
|
|
|
@@ -86,6 +93,30 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
|
|
|
86
93
|
return `❌ ${commandName} command rejected.\n\n🚫 Reason: ${reason}\n\nUse /start to resume (chat owner only).`;
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Extract a session UUID for `/stop`. Priority:
|
|
98
|
+
* 1. UUID literal anywhere in the `/stop` message text.
|
|
99
|
+
* 2. UUID in the text/caption of the message being replied to.
|
|
100
|
+
*
|
|
101
|
+
* The `text` argument is the raw `/stop ...` command text. `repliedTo`, when
|
|
102
|
+
* present, is the Telegram message object that the user replied to with `/stop`.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text
|
|
105
|
+
* @param {Object|null|undefined} repliedTo
|
|
106
|
+
* @returns {{ sessionId: string|null, source: 'argument'|'reply'|null }}
|
|
107
|
+
*/
|
|
108
|
+
export function extractStopSessionId(text, repliedTo) {
|
|
109
|
+
// Strip the leading `/stop` (or `/stop@botname`) before looking for a UUID,
|
|
110
|
+
// so we don't accidentally match digits inside the command name itself.
|
|
111
|
+
const argText = String(text || '').replace(/^\/stop(?:@\w+)?\s*/i, '');
|
|
112
|
+
const direct = extractSessionIdFromText(argText);
|
|
113
|
+
if (direct) return { sessionId: direct, source: 'argument' };
|
|
114
|
+
const replyText = repliedTo ? `${repliedTo.text || ''}\n${repliedTo.caption || ''}` : '';
|
|
115
|
+
const fromReply = extractSessionIdFromText(replyText);
|
|
116
|
+
if (fromReply) return { sessionId: fromReply, source: 'reply' };
|
|
117
|
+
return { sessionId: null, source: null };
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
/**
|
|
90
121
|
* Registers the /start and /stop command handlers with the bot
|
|
91
122
|
* @param {Object} bot - The Telegraf bot instance
|
|
@@ -95,9 +126,13 @@ export function getStoppedChatRejectMessage(chatId, commandName = 'Command') {
|
|
|
95
126
|
* @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
|
|
96
127
|
* @param {Function} options.isGroupChat - Function to check if chat is a group
|
|
97
128
|
* @param {Function} options.isChatAuthorized - Function to check if chat is authorized
|
|
129
|
+
* @param {Function} [options.isTopicAuthorized] - Topic-level authorization fallback
|
|
130
|
+
* @param {Function} [options.buildAuthErrorMessage] - Builds the chat-not-authorized message
|
|
131
|
+
* @param {Function} [options.stopIsolatedSession] - Override for tests; calls `$ --stop <uuid>`
|
|
98
132
|
*/
|
|
99
133
|
export function registerStartStopCommands(bot, options) {
|
|
100
|
-
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized } = options;
|
|
134
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage } = options;
|
|
135
|
+
const stopIsolatedSessionImpl = options.stopIsolatedSession || (async (...args) => (await import('./isolation-runner.lib.mjs')).stopIsolatedSession(...args));
|
|
101
136
|
|
|
102
137
|
/**
|
|
103
138
|
* Validate command context: checks old message, forwarded, group chat, authorized, and owner status.
|
|
@@ -145,9 +180,110 @@ export function registerStartStopCommands(bot, options) {
|
|
|
145
180
|
return { valid: true, chatId };
|
|
146
181
|
}
|
|
147
182
|
|
|
148
|
-
// /stop command
|
|
149
|
-
//
|
|
183
|
+
// /stop command. Two modes:
|
|
184
|
+
// 1. `/stop <UUID>` or reply-to-message-with-UUID — forward CTRL+C to the
|
|
185
|
+
// matching isolated session via `$ --stop <UUID>` (issue #524).
|
|
186
|
+
// 2. bare `/stop` (optionally with a free-text reason) — pause new task
|
|
187
|
+
// acceptance for the chat (issue #1081).
|
|
188
|
+
// Only accessible by chat owner (creator) in both modes.
|
|
150
189
|
bot.command('stop', async ctx => {
|
|
190
|
+
VERBOSE && console.log('[VERBOSE] /stop command received');
|
|
191
|
+
if (isOldMessage(ctx)) {
|
|
192
|
+
VERBOSE && console.log('[VERBOSE] /stop ignored: old message');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Detect UUID modes BEFORE the forwarded/reply rejection used by the
|
|
197
|
+
// chat-level stop, because the UUID-from-reply mode is intentionally a
|
|
198
|
+
// reply (issue #524).
|
|
199
|
+
const message = ctx.message;
|
|
200
|
+
const repliedTo = message?.reply_to_message || null;
|
|
201
|
+
const { sessionId, source } = extractStopSessionId(message?.text || '', repliedTo);
|
|
202
|
+
|
|
203
|
+
if (sessionId) {
|
|
204
|
+
VERBOSE && console.log(`[VERBOSE] /stop: detected UUID ${sessionId} (source=${source})`);
|
|
205
|
+
// Reuse the same auth model as /log: must be chat owner in groups; in
|
|
206
|
+
// private DMs the user is implicitly the owner of their own chat.
|
|
207
|
+
const chatId = ctx.chat?.id;
|
|
208
|
+
const chatType = ctx.chat?.type;
|
|
209
|
+
if (chatType !== 'private') {
|
|
210
|
+
if (!isGroupChat(ctx)) {
|
|
211
|
+
await ctx.reply('❌ The /stop command only works in group chats or private chats with the bot.', { reply_to_message_id: message.message_id });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!isChatAuthorized(chatId)) {
|
|
215
|
+
if (!isTopicAuthorized || !isTopicAuthorized(ctx)) {
|
|
216
|
+
const errMsg = buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${chatId}) is not authorized to use this bot.`;
|
|
217
|
+
await ctx.reply(errMsg, { reply_to_message_id: message.message_id });
|
|
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
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const ack = await ctx.reply(`⏹️ Asking session \`${sessionId}\` to stop (sending CTRL+C via \`$ --stop\`)…`, {
|
|
236
|
+
parse_mode: 'Markdown',
|
|
237
|
+
reply_to_message_id: message.message_id,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
let result;
|
|
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) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const trimmedOutput = (result.output || '').toString().trim();
|
|
249
|
+
const trimmedError = (result.error || '').toString().trim();
|
|
250
|
+
const lines = [];
|
|
251
|
+
if (result.success) {
|
|
252
|
+
lines.push(`✅ Stop request sent to session \`${sessionId}\`.`);
|
|
253
|
+
lines.push('');
|
|
254
|
+
lines.push('The session should terminate shortly.');
|
|
255
|
+
if (trimmedOutput) {
|
|
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
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await ctx.telegram.editMessageText(ack.chat.id, ack.message_id, undefined, lines.join('\n'), { parse_mode: 'Markdown' });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
console.error('[ERROR] /stop <UUID>: editMessageText failed, falling back to reply:', error);
|
|
275
|
+
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown', reply_to_message_id: message.message_id });
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// No UUID — fall through to the chat-level pause flow. That flow rejects
|
|
281
|
+
// forwards/replies on purpose (#1081) so a stray reply doesn't pause the chat.
|
|
282
|
+
if (isForwardedOrReply(ctx)) {
|
|
283
|
+
VERBOSE && console.log('[VERBOSE] /stop ignored: forwarded or reply');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
151
287
|
const check = await validateOwnerCommand(ctx, '/stop');
|
|
152
288
|
if (!check.valid) return;
|
|
153
289
|
const chatId = check.chatId;
|
|
@@ -173,12 +173,18 @@ async function querySessionStatusWithRetry(querySessionStatus, sessionId, verbos
|
|
|
173
173
|
|
|
174
174
|
// Note: /terminal_watch never uploads the full session log itself (issue #1720).
|
|
175
175
|
// Use /log <uuid> if you want the log file delivered as a document.
|
|
176
|
-
|
|
176
|
+
function getDisplayedTerminalSnapshot(logText, options) {
|
|
177
|
+
return sanitizeCodeBlock(tailTextForTerminal(logText, options));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options = {}, repoDescription = null, verbose = false, initialStatusResult = null, initialLogText = null, initialMessage = '' }) {
|
|
177
181
|
const key = `${chatId}:${messageId}:${sessionId}`;
|
|
178
182
|
activeWatches.get(key)?.stop();
|
|
179
183
|
|
|
180
184
|
let stopped = false;
|
|
181
|
-
|
|
185
|
+
const hasInitialLogText = initialLogText !== null && initialLogText !== undefined;
|
|
186
|
+
let lastSnapshot = hasInitialLogText ? getDisplayedTerminalSnapshot(initialLogText, options) : null;
|
|
187
|
+
let lastMessage = initialMessage || (hasInitialLogText ? formatTerminalWatchMessage({ sessionId, statusResult: initialStatusResult, logText: initialLogText, options, updateCount: 0, completed: !!initialStatusResult?.status && isTerminalSessionStatus(initialStatusResult.status), repoDescription }) : '');
|
|
182
188
|
let updateCount = 0;
|
|
183
189
|
let timer = null;
|
|
184
190
|
const intervalMs = options.intervalMs || DEFAULT_INTERVAL_MS;
|
|
@@ -189,11 +195,16 @@ export function watchTerminalLogSession({ bot, chatId, messageId, sessionId, log
|
|
|
189
195
|
const statusResult = await querySessionStatus(sessionId, verbose);
|
|
190
196
|
const completed = !!statusResult?.status && isTerminalSessionStatus(statusResult.status);
|
|
191
197
|
const logText = await readLogFile(logPath);
|
|
192
|
-
const
|
|
193
|
-
|
|
198
|
+
const snapshot = getDisplayedTerminalSnapshot(logText, options);
|
|
199
|
+
const snapshotChanged = snapshot !== lastSnapshot;
|
|
200
|
+
if (snapshotChanged) updateCount++;
|
|
201
|
+
const message = formatTerminalWatchMessage({ sessionId, statusResult, logText, options, updateCount, completed, repoDescription });
|
|
202
|
+
const shouldEdit = !lastMessage || snapshotChanged || (completed && message !== lastMessage);
|
|
203
|
+
if (shouldEdit && message !== lastMessage) {
|
|
194
204
|
await bot.telegram.editMessageText(chatId, messageId, undefined, message, { parse_mode: 'Markdown' });
|
|
195
205
|
lastMessage = message;
|
|
196
206
|
}
|
|
207
|
+
lastSnapshot = snapshot;
|
|
197
208
|
if (completed) {
|
|
198
209
|
stopped = true;
|
|
199
210
|
activeWatches.delete(key);
|
|
@@ -255,14 +266,15 @@ async function startWatchFromResolvedSession({ bot, ctx, sessionId, statusResult
|
|
|
255
266
|
if (!targetChatId) return { started: false, reason: 'Missing target chat id' };
|
|
256
267
|
|
|
257
268
|
const initialLogText = await readLogFile(logPath);
|
|
258
|
-
const
|
|
269
|
+
const initialCompleted = !!statusResult?.status && isTerminalSessionStatus(statusResult.status);
|
|
270
|
+
const initialText = formatTerminalWatchMessage({ sessionId, statusResult, logText: initialLogText, options: watchOptions, completed: initialCompleted, repoDescription });
|
|
259
271
|
let replyToMessageId = ctx.message?.message_id || undefined;
|
|
260
272
|
if (decision.destination === 'dm' && ctx.chat.type !== 'private') {
|
|
261
273
|
replyToMessageId = await forwardOrCopyToDm(ctx, ctx.message?.reply_to_message || ctx.message);
|
|
262
274
|
}
|
|
263
275
|
|
|
264
276
|
const watchMessage = await createWatchMessage({ ctx, targetChatId, replyToMessageId, text: initialText });
|
|
265
|
-
watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose });
|
|
277
|
+
watchTerminalLogSession({ bot, chatId: targetChatId, messageId: watchMessage.message_id, sessionId, logPath, querySessionStatus, isTerminalSessionStatus, options: watchOptions, repoDescription, verbose, initialStatusResult: statusResult, initialLogText, initialMessage: initialText });
|
|
266
278
|
|
|
267
279
|
if (!auto && decision.destination === 'dm' && ctx.chat.type !== 'private') {
|
|
268
280
|
await ctx.reply(`📬 Started terminal watch for \`${sessionId}\` in your direct messages.`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|