From 5a66a01e8ed8064bc8f5f41b5a9f9b2ef16bac84 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Wed, 23 Sep 2026 14:25:22 +0300 Subject: [PATCH 1/2] force system lang --- src/preload/dom/deepseek-language.js | 206 +++++++++++++++++++++++++++ src/preload/index.js | 4 + 2 files changed, 210 insertions(+) create mode 100644 src/preload/dom/deepseek-language.js diff --git a/src/preload/dom/deepseek-language.js b/src/preload/dom/deepseek-language.js new file mode 100644 index 0000000..306fc8c --- /dev/null +++ b/src/preload/dom/deepseek-language.js @@ -0,0 +1,206 @@ +/** + * Фиксация языка интерфейса DeepSeek на «Система» (System) и запрет его смены. + * + * Работает на родной вкладке General в модалке настроек DeepSeek: + * строка «Language/Язык/语言» содержит кастомный селект .ds-select. + * + * Алгоритм: + * 1) находим Language-селект (первый .ds-select в строке с текстом Language); + * 2) если текущее значение не System — программно выбираем System через + * React onClick соответствующей опции (открывая меню через onMouseDown); + * 3) блокируем селект: pointer-events:none, tabindex=-1, aria-disabled, + * гасим onMouseDown/onKeyDown, визуально приглушаем. + * + * Все операции обёрнуты в safe(): при изменении вёрстки модуль просто + * пропускает шаг, не ломая приложение. + */ + +const { safe } = require("./safe"); + +const LANGUAGE_LABELS = ["Language", "Язык", "语言"]; +const SYSTEM_LABELS = ["System", "Система", "系统"]; + +let started = false; +// Флаг защиты от повторного входа (программный клик по опции). +let applying = false; + +/** + * Найти селект языка: .ds-select, рядом с которым (в той же строке-контейнере) + * есть текст Language/Язык/语言. + * @returns {HTMLElement|null} + */ +function findLanguageSelect() { + return safe( + "deepseek-language.findSelect", + () => { + const selects = document.querySelectorAll(".ds-select"); + for (const sel of selects) { + const row = sel.parentElement; + if (!row) continue; + const rowText = (row.textContent || "").trim(); + if (LANGUAGE_LABELS.some((lbl) => rowText.indexOf(lbl) !== -1)) + return sel; + } + return null; + }, + null, + ); +} + +/** Достать React-props узла (или null). */ +function getReactProps(node) { + return safe( + "deepseek-language.getReactProps", + () => { + if (!node) return null; + const key = Object.keys(node).find( + (k) => k.indexOf("__reactProps") === 0, + ); + return key ? node[key] : null; + }, + null, + ); +} + +/** Текущее отображаемое значение селекта языка. */ +function getCurrentValue(sel) { + return safe( + "deepseek-language.getCurrentValue", + () => { + return sel ? (sel.textContent || "").trim() : ""; + }, + "", + ); +} + +/** + * Открыть меню селекта через его React onMouseDown. + * @returns {boolean} + */ +function openSelectMenu(sel) { + return safe( + "deepseek-language.openMenu", + () => { + const props = getReactProps(sel); + if (!props || typeof props.onMouseDown !== "function") return false; + props.onMouseDown({ + type: "mousedown", + button: 0, + currentTarget: sel, + target: sel, + preventDefault() {}, + stopPropagation() {}, + }); + return true; + }, + false, + ); +} + +/** + * Найти опцию «Система» среди открытых .ds-select-option. + * @returns {HTMLElement|null} + */ +function findSystemOption() { + return safe( + "deepseek-language.findSystemOption", + () => { + const opts = document.querySelectorAll(".ds-select-option"); + for (const opt of opts) { + const tx = (opt.textContent || "").trim(); + if (SYSTEM_LABELS.indexOf(tx) !== -1) return opt; + } + return null; + }, + null, + ); +} + +/** Выбрать опцию через её React onClick. */ +function clickOption(opt) { + return safe( + "deepseek-language.clickOption", + () => { + const props = getReactProps(opt); + if (!props || typeof props.onClick !== "function") return false; + props.onClick({ + type: "click", + button: 0, + currentTarget: opt, + target: opt, + preventDefault() {}, + stopPropagation() {}, + }); + return true; + }, + false, + ); +} + +/** Заблокировать селект языка. */ +function lockSelect(sel) { + return safe("deepseek-language.lock", () => { + if (!sel) return; + sel.setAttribute("tabindex", "-1"); + sel.setAttribute("aria-disabled", "true"); + sel.style.pointerEvents = "none"; + sel.style.cursor = "not-allowed"; + sel.style.opacity = "0.7"; + // Гасим обработчики, чтобы меню не открывалось. + const props = getReactProps(sel); + if (props) { + props.onMouseDown = function () {}; + props.onKeyDown = function () {}; + props.onFocus = function () {}; + } + }); +} + +/** + * Основное действие: выставить System и заблокировать селект. + */ +function enforce() { + if (applying) return; + return safe("deepseek-language.enforce", () => { + const sel = findLanguageSelect(); + if (!sel) return; + + const current = getCurrentValue(sel); + const isSystem = SYSTEM_LABELS.indexOf(current) !== -1; + + if (!isSystem) { + applying = true; + const opened = openSelectMenu(sel); + if (!opened) { + applying = false; + lockSelect(sel); + return; + } + // Меню рендерится асинхронно — ждём и выбираем System. + setTimeout(() => { + const opt = findSystemOption(); + if (opt) clickOption(opt); + applying = false; + lockSelect(sel); + }, 300); + } else { + lockSelect(sel); + } + }); +} + +function startWatch() { + if (started) return; + started = true; + + // Реакция на появление/изменение модалки настроек. + safe("deepseek-language.observeDoc", () => { + const mo = new MutationObserver(() => enforce()); + mo.observe(document.documentElement, { childList: true, subtree: true }); + }); + + safe("deepseek-language.enforceStart", () => enforce()); + setInterval(() => enforce(), 1000); +} + +module.exports = { startWatch, enforce }; diff --git a/src/preload/index.js b/src/preload/index.js index 7fafd5d..dabad87 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -27,6 +27,7 @@ const background = require("./dom/background"); const reasoningGlass = require("./dom/reasoning-glass"); const inputGlass = require("./dom/input-glass"); const forceDarkTheme = require("./dom/force-dark-theme"); +const deepseekLanguage = require("./dom/deepseek-language"); const qrOverride = require("./dom/qr-override"); const fileChip = require("./dom/file-chip"); const contextPort = require("./dom/context-port"); @@ -191,6 +192,9 @@ async function init() { // Принудительно держим тёмную тему DeepSeek safe("init.forceDarkThemeStart", () => forceDarkTheme.startWatch()); + // Фиксируем язык интерфейса DeepSeek на «Система» и запрещаем его менять + safe("init.deepseekLanguageStart", () => deepseekLanguage.startWatch()); + // Подмена QR-кода в попапе «Скачать приложение» safe("init.qrOverrideStart", () => qrOverride.startWatch()); From 96cfc7701035d353803dd3bc5ae46a4aae93f704 Mon Sep 17 00:00:00 2001 From: MerfiDEV Date: Wed, 23 Sep 2026 23:57:36 +0300 Subject: [PATCH 2/2] feat(telegram): notify and kill long-running processes via bot button --- CHANGELOG.md | 14 +++ botsrc/i18n.js | 16 ++++ botsrc/index.js | 91 ++++++++++++++++++ src/main/ipc.js | 2 +- src/main/process-manager.js | 108 ++++++++++++++++++--- test/main/process-manager.test.js | 44 +++++++++ tools/BashTool.js | 152 +++++++++++++++++------------- tools/JsRunner.js | 2 +- tools/PwshTool.js | 109 +++++++++++---------- 9 files changed, 411 insertions(+), 127 deletions(-) create mode 100644 test/main/process-manager.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ea9a9b7..6467538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/botsrc/i18n.js b/botsrc/i18n.js index c18b3f0..9191156 100644 --- a/botsrc/i18n.js +++ b/botsrc/i18n.js @@ -231,6 +231,14 @@ const STRINGS = { "approval.deniedShort": "❌ Отклонено", "approval.closed": "Запрос уже закрыт", + // ===== Долгий процесс ===== + "longproc.title": "⏳ Долгий процесс", + "longproc.body": "Команда выполняется более {sec} с:", + "longproc.kill": "🛑 Убить процесс", + "longproc.killed": "🛑 Процесс остановлен пользователем", + "longproc.killFailed": "⚠️ Не удалось остановить процесс", + "longproc.gone": "Процесс уже завершён", + // ===== Вопросы от AI ===== "question.title": "❓ Вопрос от ИИ", "question.hint": "Выберите вариант кнопкой ниже.", @@ -469,6 +477,14 @@ const STRINGS = { "approval.deniedShort": "❌ Denied", "approval.closed": "Request already closed", + // ===== Long process ===== + "longproc.title": "⏳ Long-running process", + "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": "❓ Question from AI", "question.hint": "Pick an option with the button below.", diff --git a/botsrc/index.js b/botsrc/index.js index a3a0239..724ce3c 100644 --- a/botsrc/index.js +++ b/botsrc/index.js @@ -221,6 +221,11 @@ const _pendingQuestions = new Map(); const _callbackTokens = new Map(); const _pendingApprovals = new Map(); const _approvalCallbackTokens = new Map(); +/** + * Реестр уведомлений о долгих процессах. + * key = token ('k') → { pid, messageId } + */ +const _longProcessTokens = new Map(); let _cbTokenCounter = 0; let _onQuestionAnswered = null; @@ -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); @@ -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
" +
+    escapeHtml(cmd) +
+    "
"; + 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; @@ -2327,6 +2417,7 @@ module.exports = { notifyAllDone, requestApproval, cancelApproval, + notifyLongProcess, askQuestion, setOnQuestionAnswered, _handleIncoming, diff --git a/src/main/ipc.js b/src/main/ipc.js index bd6a912..3940043 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -721,7 +721,7 @@ function registerIpcHandlers() { }); }, ); - processManager.track(child); + processManager.track(child, trimmed); }); }); diff --git a/src/main/process-manager.js b/src/main/process-manager.js index b275a44..1052a1f 100644 --- a/src/main/process-manager.js +++ b/src/main/process-manager.js @@ -1,8 +1,10 @@ -/** +/** * process-manager.js * Отслеживание и экстренная остановка (kill) активных дочерних процессов команд. */ -const { exec } = require('child_process'); +const { exec } = require("child_process"); + +const LONG_PROCESS_THRESHOLD_MS = 10000; class ProcessManager { constructor() { @@ -10,6 +12,8 @@ class ProcessManager { // PID, завершённые пользователем через кнопку Kill — чтобы инструменты // могли отличить намеренную остановку от обычного ненулевого exit code. this.killedPids = new Set(); + // Таймеры уведомления о долгом процессе: child -> timer + this.longProcessTimers = new Map(); } /** Пометить pid как убитый пользователем (вызывается из killAll). */ @@ -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() { @@ -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) => { @@ -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(); } diff --git a/test/main/process-manager.test.js b/test/main/process-manager.test.js new file mode 100644 index 0000000..bd04160 --- /dev/null +++ b/test/main/process-manager.test.js @@ -0,0 +1,44 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert'); +const { spawn } = require('child_process'); +const { processManager } = require('../../src/main/process-manager'); + +test('ProcessManager: track и untrack очищают таймеры и процессы', () => { + const fakeChild = { + pid: 999999, + once: () => {}, + }; + processManager.track(fakeChild, 'ping 127.0.0.1'); + assert.ok(processManager.activeProcesses.has(fakeChild)); + assert.ok(processManager.longProcessTimers.has(fakeChild)); + + processManager.untrack(fakeChild); + assert.strictEqual(processManager.activeProcesses.has(fakeChild), false); + assert.strictEqual(processManager.longProcessTimers.has(fakeChild), false); +}); + +test('ProcessManager: killProcess для несуществующего pid возвращает ошибку', async () => { + const r = await processManager.killProcess(1234567); + assert.strictEqual(r.success, false); + assert.ok(/не найден/.test(r.error)); +}); + +test('ProcessManager: killProcess завершает реальный процесс и помечает killed', async () => { + const isWin = process.platform === 'win32'; + const child = isWin + ? spawn('ping', ['-n', '20', '127.0.0.1'], { windowsHide: true }) + : spawn('sleep', ['20']); + + assert.ok(child.pid); + processManager.track(child, 'ping test'); + + assert.ok(processManager.activeProcesses.has(child)); + const pid = child.pid; + + const result = await processManager.killProcess(pid); + assert.strictEqual(result.success, true); + assert.strictEqual(result.killed, true); + assert.strictEqual(processManager.wasKilled(pid), true); + assert.strictEqual(processManager.activeProcesses.has(child), false); +}); diff --git a/tools/BashTool.js b/tools/BashTool.js index 8045833..58f42ce 100644 --- a/tools/BashTool.js +++ b/tools/BashTool.js @@ -1,8 +1,8 @@ -const { Tool, ToolResult } = require('./ToolRegistry'); -const { exec } = require('child_process'); -const path = require('path'); -const { decodeOutput, normalizeCommand } = require('./decodeOutput'); -const { DEFAULT_DANGEROUS_PATTERNS } = require('../src/main/settings-store'); +const { Tool, ToolResult } = require("./ToolRegistry"); +const { exec } = require("child_process"); +const path = require("path"); +const { decodeOutput, normalizeCommand } = require("./decodeOutput"); +const { DEFAULT_DANGEROUS_PATTERNS } = require("../src/main/settings-store"); // Кэш скомпилированных RegExp (пересобирается при изменении списка) let cachedPatternsRaw = null; @@ -11,11 +11,16 @@ let cachedRegexps = null; function buildRegexps(patterns) { const out = []; for (const p of patterns) { - if (typeof p !== 'string' || !p.trim()) continue; + if (typeof p !== "string" || !p.trim()) continue; try { - out.push(new RegExp(p, 'i')); + out.push(new RegExp(p, "i")); } catch (err) { - console.warn('[Cookie Code] Невалидный regex опасной команды:', p, '—', err.message); + console.warn( + "[Cookie Code] Невалидный regex опасной команды:", + p, + "—", + err.message, + ); } } return out; @@ -32,24 +37,29 @@ const DANGEROUS_CMDS = buildRegexps(DEFAULT_DANGEROUS_PATTERNS); */ function getDangerousCmds() { try { - const settingsStore = require('../src/main/settings-store'); + const settingsStore = require("../src/main/settings-store"); const settings = settingsStore.readSettings(); const custom = settings.dangerousPatterns; - const raw = (Array.isArray(custom) && custom.length > 0) - ? custom - : DEFAULT_DANGEROUS_PATTERNS; - const signature = raw.join('||'); + const raw = + Array.isArray(custom) && custom.length > 0 + ? custom + : DEFAULT_DANGEROUS_PATTERNS; + const signature = raw.join("||"); if (signature !== cachedPatternsRaw) { cachedPatternsRaw = signature; cachedRegexps = buildRegexps(raw); } return cachedRegexps; } catch (err) { - console.error('[Cookie Code] BashTool: не удалось прочитать опасные команды:', err.message); + console.error( + "[Cookie Code] BashTool: не удалось прочитать опасные команды:", + err.message, + ); return buildRegexps(DEFAULT_DANGEROUS_PATTERNS); } } + /** * Bash 执行工具 - 最小移植 dsh 风格。 * 非零退出正常返回,附 [exit code] 标记;输出纯文本。 @@ -57,40 +67,41 @@ function getDangerousCmds() { class BashTool extends Tool { constructor() { super( - 'bash', '执行 bash 命令。非零退出以 [exit code] 标记返回,不视为错误。', + "bash", + "执行 bash 命令。非零退出以 [exit code] 标记返回,不视为错误。", { - type: 'object', + type: "object", properties: { command: { - type: 'string', - description: '要执行的 shell 命令' + type: "string", + description: "要执行的 shell 命令", }, description: { - type: 'string', - description: '命令用途说明' + type: "string", + description: "命令用途说明", }, workdir: { - type: 'string', - description: '工作目录(相对路径基于项目根目录),默认项目根目录' + type: "string", + description: "工作目录(相对路径基于项目根目录),默认项目根目录", }, timeoutMs: { - type: 'number', - description: '超时毫秒数,默认 30000', - default: 30000 - } + type: "number", + description: "超时毫秒数,默认 30000", + default: 30000, + }, }, - required: ['command'], - additionalProperties: false + required: ["command"], + additionalProperties: false, }, - 'bash(command, options?)' + "bash(command, options?)", ); } getPromptSection() { return { - name: 'tool:bash', + name: "tool:bash", order: 105, - text: '执行 bash 命令并返回 stdout/stderr。每次调用在全新 shell 中运行:状态(cwd、变量、函数)不会跨调用保留——请用 workdir 参数而非 cd。非零退出以 [exit code: N] 标记报告。长输出会截断为尾部。' + text: "执行 bash 命令并返回 stdout/stderr。每次调用在全新 shell 中运行:状态(cwd、变量、函数)不会跨调用保留——请用 workdir 参数而非 cd。非零退出以 [exit code: N] 标记报告。长输出会截断为尾部。", }; } @@ -98,19 +109,19 @@ class BashTool extends Tool { const { command, description, workdir, timeoutMs, projectDir } = params; try { - if (!command || typeof command !== 'string') { - return ToolResult.error('invalid command: expected a non-empty string'); + if (!command || typeof command !== "string") { + return ToolResult.error("invalid command: expected a non-empty string"); } const trimmed = normalizeCommand(command.trim()); if (!trimmed) { - return ToolResult.error('invalid command: expected a non-empty string'); + return ToolResult.error("invalid command: expected a non-empty string"); } // Проверка опасных команд (список берётся из настроек) const dangerous = getDangerousCmds(); if (dangerous.some((p) => p.test(trimmed))) { - return ToolResult.error('命令被安全策略拒绝(危险命令): ' + trimmed); + return ToolResult.error("命令被安全策略拒绝(危险命令): " + trimmed); } // 确定工作目录 @@ -119,55 +130,64 @@ class BashTool extends Tool { const normalized = workdir.replace(/\//g, path.sep); workDir = path.isAbsolute(normalized) ? normalized - : (projectDir ? path.join(projectDir, normalized) : path.resolve(normalized)); + : projectDir + ? path.join(projectDir, normalized) + : path.resolve(normalized); } else if (projectDir) { workDir = projectDir; } else { workDir = process.cwd(); } - const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 30000; + const timeout = + typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 30000; return await new Promise((resolve) => { let processManager = null; try { - processManager = require('../src/main/process-manager').processManager; + processManager = + require("../src/main/process-manager").processManager; } catch (_) {} - const child = exec(trimmed, { - cwd: workDir, - timeout, - maxBuffer: 1024 * 1024, - windowsHide: true, - encoding: 'buffer', - }, (error, stdout, stderr) => { - const wasKilledByUser = processManager && child && processManager.wasKilled(child.pid); - if (processManager && child) processManager.untrack(child); - const out = decodeOutput(stdout); - const err = decodeOutput(stderr); - const parts = []; - if (out) parts.push(out); - if (err) parts.push('[stderr]\n' + err); - if (error) { - if (wasKilledByUser) { - parts.push('[terminated by user]'); - } else if (error.killed) { - parts.push('[timed out]'); + const child = exec( + trimmed, + { + cwd: workDir, + timeout, + maxBuffer: 1024 * 1024, + windowsHide: true, + encoding: "buffer", + }, + (error, stdout, stderr) => { + const wasKilledByUser = + processManager && child && processManager.wasKilled(child.pid); + if (processManager && child) processManager.untrack(child); + const out = decodeOutput(stdout); + const err = decodeOutput(stderr); + const parts = []; + if (out) parts.push(out); + if (err) parts.push("[stderr]\n" + err); + if (error) { + if (wasKilledByUser) { + parts.push("[terminated by user]"); + } else if (error.killed) { + parts.push("[timed out]"); + } + const code = typeof error.code === "number" ? error.code : 1; + parts.push("[exit code: " + code + "]"); + } else { + parts.push("[exit code: 0]"); } - const code = typeof error.code === 'number' ? error.code : 1; - parts.push('[exit code: ' + code + ']'); - } else { - parts.push('[exit code: 0]'); - } - resolve(ToolResult.success(parts.join('\n'))); - }); + resolve(ToolResult.success(parts.join("\n"))); + }, + ); if (processManager && child) { - processManager.track(child); + processManager.track(child, trimmed); } }); } catch (err) { - return ToolResult.error('命令执行异常: ' + err.message); + return ToolResult.error("命令执行异常: " + err.message); } } } diff --git a/tools/JsRunner.js b/tools/JsRunner.js index a6f8f35..af72d93 100644 --- a/tools/JsRunner.js +++ b/tools/JsRunner.js @@ -304,7 +304,7 @@ function runBash(args, projectDir) { ); if (processManager && child) { - processManager.track(child); + processManager.track(child, command); } }); } diff --git a/tools/PwshTool.js b/tools/PwshTool.js index 2838993..1154d6c 100644 --- a/tools/PwshTool.js +++ b/tools/PwshTool.js @@ -1,7 +1,9 @@ -const { Tool, ToolResult } = require('./ToolRegistry'); -const { execFile } = require('child_process'); -const path = require('path'); -const { decodeOutput } = require('./decodeOutput'); +const { Tool, ToolResult } = require("./ToolRegistry"); +const { execFile } = require("child_process"); +const path = require("path"); +const { decodeOutput } = require("./decodeOutput"); + + // PowerShell 危险命令列表(额外覆盖 PowerShell 特有危险操作) const DANGEROUS_PWSH_CMDS = [ @@ -28,41 +30,41 @@ const DANGEROUS_PWSH_CMDS = [ class PwshTool extends Tool { constructor() { super( - 'pwsh', - '执行 PowerShell 命令(powershell -NoProfile -Command)。非零退出以 [exit code] 标记返回,不视为错误。', + "pwsh", + "执行 PowerShell 命令(powershell -NoProfile -Command)。非零退出以 [exit code] 标记返回,不视为错误。", { - type: 'object', + type: "object", properties: { command: { - type: 'string', - description: '要执行的 PowerShell 命令' + type: "string", + description: "要执行的 PowerShell 命令", }, description: { - type: 'string', - description: '命令用途说明' + type: "string", + description: "命令用途说明", }, workdir: { - type: 'string', - description: '工作目录(相对路径基于项目根目录),默认项目根目录' + type: "string", + description: "工作目录(相对路径基于项目根目录),默认项目根目录", }, timeoutMs: { - type: 'number', - description: '超时毫秒数,默认 30000', - default: 30000 - } + type: "number", + description: "超时毫秒数,默认 30000", + default: 30000, + }, }, - required: ['command'], - additionalProperties: false + required: ["command"], + additionalProperties: false, }, - 'pwsh(command, options?)' + "pwsh(command, options?)", ); } getPromptSection() { return { - name: 'tool:pwsh', + name: "tool:pwsh", order: 106, - text: '执行 PowerShell 命令(powershell -NoProfile -Command)并返回 stdout/stderr。每次调用在全新 pwsh 进程中运行:状态不会跨调用保留——请用 workdir 参数而非 cd。路径使用 Windows 原生形式(C:\\...);用 $env:NAME 读取环境变量。非零退出以 [exit code: N] 标记报告。' + text: "执行 PowerShell 命令(powershell -NoProfile -Command)并返回 stdout/stderr。每次调用在全新 pwsh 进程中运行:状态不会跨调用保留——请用 workdir 参数而非 cd。路径使用 Windows 原生形式(C:\\...);用 $env:NAME 读取环境变量。非零退出以 [exit code: N] 标记报告。", }; } @@ -70,18 +72,18 @@ class PwshTool extends Tool { const { command, description, workdir, timeoutMs, projectDir } = params; try { - if (!command || typeof command !== 'string') { - return ToolResult.error('invalid command: expected a non-empty string'); + if (!command || typeof command !== "string") { + return ToolResult.error("invalid command: expected a non-empty string"); } const trimmed = command.trim(); if (!trimmed) { - return ToolResult.error('invalid command: expected a non-empty string'); + return ToolResult.error("invalid command: expected a non-empty string"); } // 危险命令检查 if (DANGEROUS_PWSH_CMDS.some((p) => p.test(trimmed))) { - return ToolResult.error('命令被安全策略拒绝(危险命令): ' + trimmed); + return ToolResult.error("命令被安全策略拒绝(危险命令): " + trimmed); } // 确定工作目录 @@ -90,29 +92,40 @@ class PwshTool extends Tool { const normalized = workdir.replace(/\//g, path.sep); workDir = path.isAbsolute(normalized) ? normalized - : (projectDir ? path.join(projectDir, normalized) : path.resolve(normalized)); + : projectDir + ? path.join(projectDir, normalized) + : path.resolve(normalized); } else if (projectDir) { workDir = projectDir; } else { - workDir = process.env.USERPROFILE || process.env.HOME || 'C:\\'; + workDir = process.env.USERPROFILE || process.env.HOME || "C:\\"; } - const timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 30000; + const timeout = + typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 30000; - console.log('[PwshTool] 执行命令: ' + trimmed + ', cwd=' + workDir); + console.log("[PwshTool] 执行命令: " + trimmed + ", cwd=" + workDir); return await new Promise((resolve) => { let processManager = null; try { - processManager = require('../src/main/process-manager').processManager; + processManager = + require("../src/main/process-manager").processManager; } catch (_) {} const child = execFile( - 'powershell', - ['-NoProfile', '-Command', trimmed], - { cwd: workDir, timeout, maxBuffer: 1024 * 1024, windowsHide: true, encoding: 'buffer' }, + "powershell", + ["-NoProfile", "-Command", trimmed], + { + cwd: workDir, + timeout, + maxBuffer: 1024 * 1024, + windowsHide: true, + encoding: "buffer", + }, (error, stdout, stderr) => { - const wasKilledByUser = processManager && child && processManager.wasKilled(child.pid); + const wasKilledByUser = + processManager && child && processManager.wasKilled(child.pid); if (processManager && child) processManager.untrack(child); const out = decodeOutput(stdout); const err = decodeOutput(stderr); @@ -120,39 +133,39 @@ class PwshTool extends Tool { // dsh 风格渲染 let body = out; if (err && err.length > 0) { - if (body.length > 0 && !body.endsWith('\n')) body += '\n'; - body += '[stderr]\n' + err; + if (body.length > 0 && !body.endsWith("\n")) body += "\n"; + body += "[stderr]\n" + err; } - if (body.length === 0) body = '(no output)'; + if (body.length === 0) body = "(no output)"; const markers = []; if (error) { if (wasKilledByUser) { - markers.push('[terminated by user]'); + markers.push("[terminated by user]"); } else if (error.killed) { - markers.push('[timed out after ' + timeout + 'ms]'); - } else if (typeof error.code === 'number') { - markers.push('[exit code: ' + error.code + ']'); + markers.push("[timed out after " + timeout + "ms]"); + } else if (typeof error.code === "number") { + markers.push("[exit code: " + error.code + "]"); } else { - markers.push('[exit code: 1]'); + markers.push("[exit code: 1]"); } } if (markers.length > 0) { - if (!body.endsWith('\n')) body += '\n'; - body += markers.join('\n'); + if (!body.endsWith("\n")) body += "\n"; + body += markers.join("\n"); } resolve(ToolResult.success(body)); - } + }, ); if (processManager && child) { - processManager.track(child); + processManager.track(child, trimmed); } }); } catch (err) { - return ToolResult.error('命令执行异常: ' + err.message); + return ToolResult.error("命令执行异常: " + err.message); } } }