Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## [4.0.33] - 2026-09-23

### Added

- ⏳ **Уведомление в Telegram о долгих процессах с кнопкой «🛑 Убить процесс»** — если команда bash/pwsh или скрипт AI выполняется дольше 10 секунд, бот присылает сообщение с таймером и кнопкой экстренной остановки:
- работает для любых команд: и запущенных AI через `JsRunner`, и классических инструментов `bash`/`pwsh`, и ручных запусков из блоков кода в чате :)
- по нажатию кнопки процесс мгновенно завершается вместе со всем деревом процессов (`taskkill /T /F` на Windows);
- сообщение в Telegram красиво обновляется на «🛑 Процесс остановлен пользователем» или сообщает, если процесс уже успел завершиться сам;
- если Telegram-бот в настройках выключен или не настроен — приложение работает в обычном режиме без задержек и лишних запросов.

### Fixed

- 🪓 **Остановка зависших процессов** — теперь кнопка завершения в Telegram корректно находит активный дочерний процесс и гарантированно гасит всё дерево процессов на Windows.

## [4.0.32] - 2026-09-19

### Added
Expand Down
16 changes: 16 additions & 0 deletions botsrc/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ const STRINGS = {
"approval.deniedShort": "❌ Отклонено",
"approval.closed": "Запрос уже закрыт",

// ===== Долгий процесс =====
"longproc.title": "⏳ <b>Долгий процесс</b>",
"longproc.body": "Команда выполняется более {sec} с:",
"longproc.kill": "🛑 Убить процесс",
"longproc.killed": "🛑 Процесс остановлен пользователем",
"longproc.killFailed": "⚠️ Не удалось остановить процесс",
"longproc.gone": "Процесс уже завершён",

// ===== Вопросы от AI =====
"question.title": "❓ <b>Вопрос от ИИ</b>",
"question.hint": "<i>Выберите вариант кнопкой ниже.</i>",
Expand Down Expand Up @@ -469,6 +477,14 @@ const STRINGS = {
"approval.deniedShort": "❌ Denied",
"approval.closed": "Request already closed",

// ===== Long process =====
"longproc.title": "⏳ <b>Long-running process</b>",
"longproc.body": "Command has been running for over {sec}s:",
"longproc.kill": "🛑 Kill process",
"longproc.killed": "🛑 Process stopped by user",
"longproc.killFailed": "⚠️ Failed to stop process",
"longproc.gone": "Process already finished",

// ===== AI questions =====
"question.title": "❓ <b>Question from AI</b>",
"question.hint": "<i>Pick an option with the button below.</i>",
Expand Down
91 changes: 91 additions & 0 deletions botsrc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ const _pendingQuestions = new Map();
const _callbackTokens = new Map();
const _pendingApprovals = new Map();
const _approvalCallbackTokens = new Map();
/**
* Реестр уведомлений о долгих процессах.
* key = token ('k<number>') → { pid, messageId }
*/
const _longProcessTokens = new Map();
let _cbTokenCounter = 0;
let _onQuestionAnswered = null;

Expand Down Expand Up @@ -314,6 +319,11 @@ async function _handleCallback(chatId, data, cbq) {
await _handleApprovalCallback(approvalRef, cbq);
return;
}
const longProcRef = _longProcessTokens.get(token);
if (longProcRef) {
await _handleLongProcessCallback(token, longProcRef, cbq);
return;
}
const ref = _callbackTokens.get(token);
if (!ref) {
await telegramBot.answerCallbackQuery(cbq.id);
Expand Down Expand Up @@ -468,6 +478,86 @@ function cancelApproval(requestId) {
return { success: true };
}

/**
* Уведомить о долгоиграющем процессе и прислать кнопку «Убить процесс».
* Вызывается из bash/pwsh инструментов, если команда не завершилась за N сек.
* @param {number} pid pid дочернего процесса
* @param {string} command текст команды (для сообщения)
* @param {number} sec порог в секундах (для текста)
* @returns {Promise<{success:boolean, skipped?:boolean, error?:string}>}
*/
async function notifyLongProcess(pid, command, sec) {
const cfg = _read();
console.log(
"[LongProc] notifyLongProcess cfg:",
JSON.stringify({
enabled: cfg.enabled,
hasToken: !!cfg.token,
chatId: cfg.chatId,
}),
);
if (!cfg.enabled || !cfg.token || !cfg.chatId)
return { success: false, skipped: true };
const numPid = Number(pid);
if (!numPid) return { success: false, error: "pid не задан" };

const token = "k" + ++_cbTokenCounter;
const cmd = String(command || "").slice(0, 500);
const msg =
_t("longproc.title") +
"\n" +
_t("longproc.body", { sec: sec || 10 }) +
"\n<pre>" +
escapeHtml(cmd) +
"</pre>";
const res = await telegramBot.sendMessage(msg, {
parseMode: "HTML",
replyMarkup: {
inline_keyboard: [[{ text: _t("longproc.kill"), callback_data: token }]],
},
});
if (!res.success) return res;
_longProcessTokens.set(token, {
pid: numPid,
messageId: res.messageId || null,
});
return { success: true, token };
}

/** Обработать нажатие кнопки «Убить процесс». */
async function _handleLongProcessCallback(token, ref, cbq) {
_longProcessTokens.delete(token);
let killed = false;
let notFound = false;
try {
const { processManager } = require("../src/main/process-manager");
const r = await processManager.killProcess(ref.pid);
killed = !!(r && r.success);
notFound = !killed && !!(r && /не найден/.test(r.error || ""));
} catch (err) {
_log("error", "killProcess error:", err.message);
}
const statusText = killed
? _t("longproc.killed")
: notFound
? _t("longproc.gone")
: _t("longproc.killFailed");
await telegramBot.answerCallbackQuery(cbq.id, {
text: killed
? _t("longproc.killed")
: notFound
? _t("longproc.gone")
: _t("longproc.killFailed"),
});
if (ref.messageId) {
await telegramBot.editMessageText(
ref.messageId,
_t("longproc.title") + "\n" + statusText,
{ parseMode: "HTML", replyMarkup: { inline_keyboard: [] } },
);
}
}

/** Перерисовать сообщение с вопросами: отметить выбранное, убрать лишние кнопки. */
async function _refreshQuestionMessage(requestId, entry) {
if (!entry.messageId) return;
Expand Down Expand Up @@ -2327,6 +2417,7 @@ module.exports = {
notifyAllDone,
requestApproval,
cancelApproval,
notifyLongProcess,
askQuestion,
setOnQuestionAnswered,
_handleIncoming,
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ function registerIpcHandlers() {
});
},
);
processManager.track(child);
processManager.track(child, trimmed);
});
});

Expand Down
108 changes: 97 additions & 11 deletions src/main/process-manager.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
/**
/**
* process-manager.js
* Отслеживание и экстренная остановка (kill) активных дочерних процессов команд.
*/
const { exec } = require('child_process');
const { exec } = require("child_process");

const LONG_PROCESS_THRESHOLD_MS = 10000;

class ProcessManager {
constructor() {
this.activeProcesses = new Set();
// PID, завершённые пользователем через кнопку Kill — чтобы инструменты
// могли отличить намеренную остановку от обычного ненулевого exit code.
this.killedPids = new Set();
// Таймеры уведомления о долгом процессе: child -> timer
this.longProcessTimers = new Map();
}

/** Пометить pid как убитый пользователем (вызывается из killAll). */
Expand All @@ -28,19 +32,97 @@ class ProcessManager {
return !!pid && this.killedPids.has(pid);
}

track(child) {
track(child, command) {
if (!child || !child.pid) return;
this.activeProcesses.add(child);
const cleanup = () => {
this.clearLongProcessTimer(child);
this.activeProcesses.delete(child);
};
child.once('exit', cleanup);
child.once('close', cleanup);
child.once('error', cleanup);
child.once("exit", cleanup);
child.once("close", cleanup);
child.once("error", cleanup);

if (command && typeof command === "string") {
this.scheduleLongProcessNotify(child, command);
}
}

scheduleLongProcessNotify(child, command) {
this.clearLongProcessTimer(child);
const pid = child.pid;
const timer = setTimeout(() => {
this.longProcessTimers.delete(child);
try {
const bot = require("../../botsrc");
if (bot && typeof bot.notifyLongProcess === "function") {
bot.notifyLongProcess(
pid,
command,
Math.round(LONG_PROCESS_THRESHOLD_MS / 1000),
);
}
} catch (_) {}
}, LONG_PROCESS_THRESHOLD_MS);
if (timer.unref) timer.unref();
this.longProcessTimers.set(child, timer);
}

clearLongProcessTimer(child) {
const timer = this.longProcessTimers.get(child);
if (timer) {
clearTimeout(timer);
this.longProcessTimers.delete(child);
}
}

untrack(child) {
if (child) this.activeProcesses.delete(child);
if (child) {
this.clearLongProcessTimer(child);
this.activeProcesses.delete(child);
}
}

/**
* Убить конкретный активный дочерний процесс по pid (вместе с деревом процессов).
* Используется кнопкой «Убить процесс» в Telegram для долгих команд.
* @param {number} pid
* @returns {Promise<{success: boolean, killed?: boolean, error?: string}>}
*/
async killProcess(pid) {
const numPid = Number(pid);
if (!numPid) return { success: false, error: "pid не задан" };
let target = null;
for (const child of this.activeProcesses) {
if (child && child.pid === numPid) {
target = child;
break;
}
}
if (!target) return { success: false, error: "процесс не найден" };
this.markKilled(numPid);
this.clearLongProcessTimer(target);
this.activeProcesses.delete(target);

if (process.platform === "win32") {
await new Promise((resolve) => {
exec(`taskkill /pid ${numPid} /T /F`, { windowsHide: true }, () => {
try {
target.kill("SIGKILL");
} catch (_) {}
resolve();
});
});
} else {
try {
process.kill(-numPid, "SIGKILL");
} catch (_) {
try {
target.kill("SIGKILL");
} catch (__) {}
}
}
return { success: true, killed: true };
}

async killAll() {
Expand All @@ -50,7 +132,7 @@ class ProcessManager {
}

let killedCount = 0;
const isWin = process.platform === 'win32';
const isWin = process.platform === "win32";

const killPromises = list.map((child) => {
return new Promise((resolve) => {
Expand All @@ -64,14 +146,18 @@ class ProcessManager {
this.markKilled(pid);
if (isWin) {
exec(`taskkill /pid ${pid} /T /F`, { windowsHide: true }, () => {
try { child.kill('SIGKILL'); } catch (_) {}
try {
child.kill("SIGKILL");
} catch (_) {}
resolve();
});
} else {
try {
process.kill(-pid, 'SIGKILL');
process.kill(-pid, "SIGKILL");
} catch (_) {
try { child.kill('SIGKILL'); } catch (__) {}
try {
child.kill("SIGKILL");
} catch (__) {}
}
resolve();
}
Expand Down
Loading
Loading