') → { 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/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());
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);
}
}
}