diff --git a/.gitignore b/.gitignore index a207b48..f180931 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,9 @@ test/coverage.lcov # Playwright MCP .playwright-mcp/ + +# Скачанный бандл DeepSeek (анализ ошибок, не коммитим) +/deepseek-bundle/ /tiktok /.cuckoo/skills # Проектная память — личные заметки AI, не коммитим. diff --git a/CHANGELOG.md b/CHANGELOG.md index b2cad02..76d5c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [5.0.9] - 2026-10-26 + +### Added + +- ⛔ **Уведомление в Telegram при rate-limit DeepSeek (429 / 40029)** — при превышении лимита запросов в Telegram приходит ==КАПС-предупреждение== с просьбой подождать: + - в main-world перехватчике `token-interceptor-inject.js` в патчах `fetch`/`XHR` ловятся HTTP `429` и бизнес-код `40029` (`IP_ACCESS_RESTRICTED`) в теле ответа; + - событие уходит через `postMessage({ __cuckoo: "rate-limit" })`, preload слушает с антиспамом 60с; + - IPC `telegram-rate-limit` → `botsrc.notifyRateLimit()` шлёт сообщение с ожиданием 5–20 минут (учёт `retry-after` при наличии); + - строки локализации `rateLimit.*` (ru/en) в `botsrc/i18n.js`; в окнах-субагентах уведомление подавляется. +- 🔄 **Pull-модель получения initial-prompt в окне субагента** — если push потерялся из-за гонки IPC, окно само запрашивает промпт у main: + - `subagent.js` кэширует combined-промпт по `windowId` и отдаёт через `getInitPrompt()`; + - новый IPC-хендлер `subagent-get-init-prompt` в `src/main/ipc.js`; + - в `chat-input.js` окно-субагент через 20с запрашивает промпт, если не получило пушем. + +### Changed + +- 🪟 **Отправка initial-prompt в субагент стала устойчивее** — повторные отправки в `3с / 8с / 15с`, абсолютный таймаут до 3 минут (было: 1.2с и 60с) в `subagent.js`. +- ⏳ **Ожидание поля ввода в окне субагента** — увеличено с ~15с до 90с (180 попыток × 500мс), `pendingInitialPrompt` больше не сбрасывается по таймауту (`chat-input.js`). +- 🛡 **Защита от дубля initial-prompt** — `applyInitialPrompt()` с флагом `_initialPromptApplied` не даёт отправить промпт дважды (push + pull). + +### Fixed + +- 🐛 **Окно субагента больше не остаётся пустым без промпта** — повторные отправки + pull-запрос закрывают гонку долгой загрузки окна. +- 🧹 **DSML/XML-мусор в ответе субагента не считается финальным ответом** — проверка вынесена ==выше анализа cuckoo-блоков== (`subagent-watch.js`): + - детект по `textContent` **и** `innerHTML` (ловит мусор внутри `
` и когда теги съедены markdown-рендером);
+  - паттерны: маркеры `DSML`, `invoke/parameter/tool_calls name=` с тегом и без;
+  - при обнаружении в чат уходит предупреждение (на английском) использовать `cuckoo`-блок, ответ не завершается; лимит 3 подсказки + антидубль по снапшоту.
+
 ## [5.0.8] - 2026-10-22
 
 ### Added
diff --git a/botsrc/i18n.js b/botsrc/i18n.js
index 9610957..e11635d 100644
--- a/botsrc/i18n.js
+++ b/botsrc/i18n.js
@@ -476,6 +476,16 @@ const STRINGS = {
     // ===== Уведомление об ответе AI =====
     "ai.truncated": "…(обрезано, всего {n} симв.)",
 
+    // ===== Rate limit (превышение лимита запросов DeepSeek) =====
+    "rateLimit.title": "⛔️⛔️⛔️ ВНИМАНИЕ! ⛔️⛔️⛔️",
+    "rateLimit.exceeded": "ТЫ ПРЕВЫСИЛ ЛИМИТ ЗАПРОСОВ К DEEPSEEK!",
+    "rateLimit.tooOften": "СЛИШКОМ ЧАСТО ОТПРАВЛЯЕШЬ СООБЩЕНИЯ!",
+    "rateLimit.wait": "⏳ ПОДОЖДИ {min}–{max} МИНУТ, ПОТОМ ПРОБУЙ СНОВА!",
+    "rateLimit.serverHint": "(Сервер просит подождать минимум ~{retry} мин.)",
+    "rateLimit.dontSpam":
+      "Не отправляй сообщения подряд — это только продлит блокировку!",
+    "rateLimit.statusCode": "Код ответа: {status}",
+
     // ===== Тестовое сообщение =====
     "test.send": "👋 Cookie Code: тестовое уведомление. Всё работает.",
   },
@@ -946,6 +956,16 @@ const STRINGS = {
     // ===== AI response notification =====
     "ai.truncated": "…(truncated, {n} chars total)",
 
+    // ===== Rate limit (DeepSeek request limit exceeded) =====
+    "rateLimit.title": "⛔️⛔️⛔️ WARNING! ⛔️⛔️⛔️",
+    "rateLimit.exceeded": "YOU HAVE EXCEEDED THE DEEPSEEK REQUEST LIMIT!",
+    "rateLimit.tooOften": "YOU ARE SENDING MESSAGES TOO OFTEN!",
+    "rateLimit.wait": "⏳ WAIT {min}–{max} MINUTES, THEN TRY AGAIN!",
+    "rateLimit.serverHint": "(Server asks to wait at least ~{retry} min.)",
+    "rateLimit.dontSpam":
+      "Do not send messages back-to-back — it will only extend the block!",
+    "rateLimit.statusCode": "Response code: {status}",
+
     // ===== Test message =====
     "test.send": "👋 Cookie Code: test notification. All good.",
   },
diff --git a/botsrc/index.js b/botsrc/index.js
index 94e8b9e..4350b04 100644
--- a/botsrc/index.js
+++ b/botsrc/index.js
@@ -5461,6 +5461,43 @@ async function notifyAIResponse(text) {
   return telegramBot.sendMessage("🤖 " + body);
 }
 
+/**
+ * Уведомить о превышении rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED=40029).
+ * Пишет КАПСОМ, что пользователь превысил лимит и нужно подождать 5-20 минут.
+ * @param {{status?:number, retryAfterSec?:number}} [info]
+ */
+async function notifyRateLimit(info) {
+  const cfg = _read();
+  if (!cfg.enabled || !cfg.token || !cfg.chatId)
+    return { success: false, skipped: true };
+
+  // Случайная задержка ожидания 5–20 минут.
+  const minMin = 5;
+  const maxMin = 20;
+  const waitMin = minMin + Math.floor(Math.random() * (maxMin - minMin + 1));
+
+  // Если сервер прислал retry-after — используем его как ориентир (в минутах).
+  const retry =
+    info && Number(info.retryAfterSec) > 0
+      ? Math.ceil(Number(info.retryAfterSec) / 60)
+      : 0;
+
+  const lines = [
+    _t("rateLimit.title"),
+    "",
+    _t("rateLimit.exceeded"),
+    _t("rateLimit.tooOften"),
+    "",
+    _t("rateLimit.wait", { min: waitMin, max: maxMin }),
+  ];
+  if (retry > 0) lines.push(_t("rateLimit.serverHint", { retry }));
+  lines.push("", _t("rateLimit.dontSpam"));
+  if (info && info.status)
+    lines.push(_t("rateLimit.statusCode", { status: info.status }));
+
+  return telegramBot.sendMessage(lines.join("\n"));
+}
+
 /** Пингануть бота (getMe) для проверки токена. */
 async function ping() {
   const cfg = _read();
@@ -5483,6 +5520,7 @@ module.exports = {
   notifyToolResult,
   notifyAgentStarted,
   notifyAIResponse,
+  notifyRateLimit,
   startTyping,
   stopTyping,
   notifyAllDone,
diff --git a/src/main/ipc.js b/src/main/ipc.js
index 82982ad..014d1a1 100644
--- a/src/main/ipc.js
+++ b/src/main/ipc.js
@@ -407,6 +407,22 @@ function registerIpcHandlers() {
     }
   });
 
+  // Pull-модель: дочернее окно само запрашивает initial-prompt, если push
+  // потерялся из-за гонки IPC (окно долго грузилось и не успело подписаться).
+  ipcMain.handle("subagent-get-init-prompt", async (event) => {
+    try {
+      const ctx = windowState.getContextByWebContents(event.sender);
+      if (!ctx || !ctx.win || ctx.win.isDestroyed()) {
+        return { success: false, error: "no window context" };
+      }
+      const text = subagent.getInitPrompt(ctx.win.id);
+      if (!text) return { success: false, error: "no init prompt" };
+      return { success: true, text };
+    } catch (err) {
+      return { success: false, error: err.message };
+    }
+  });
+
   // Мост Telegram → окно: ответ на вопрос из TG резолвит тот же promise.
   try {
     const bot = require("../../botsrc");
@@ -1450,6 +1466,20 @@ function registerIpcHandlers() {
     }
   });
 
+  // Rate-limit DeepSeek (429 / IP_ACCESS_RESTRICTED) -> КАПС-уведомление в Telegram.
+  ipcMain.handle("telegram-rate-limit", async (event, info = {}) => {
+    try {
+      const ctx = windowState.getContextByWebContents(event.sender);
+      if (ctx && ctx.isSubagent) {
+        return { success: false, skipped: true, reason: "subagent window" };
+      }
+      const bot = require("../../botsrc");
+      return await bot.notifyRateLimit(info);
+    } catch (err) {
+      return { success: false, error: err.message };
+    }
+  });
+
   // Индикатор «ИИ печатает…» в Telegram (пока идёт генерация ответа).
   ipcMain.handle("telegram-typing-start", async () => {
     try {
diff --git a/src/main/subagent.js b/src/main/subagent.js
index 0c35387..350070b 100644
--- a/src/main/subagent.js
+++ b/src/main/subagent.js
@@ -79,6 +79,15 @@ function buildAgentPrompt(agentSystemPrompt, task) {
  * Дождаться готовности дочернего окна и отправить в него initial-prompt
  * (system prompt Cookie Code + промпт агента + task).
  */
+// combined initial-prompt по windowId — чтобы окно могло запросить его повторно
+// (pull-модель), если push-сообщение потерялось из-за гонки IPC.
+const initPrompts = new Map();
+
+/** Получить сохранённый initial-prompt по windowId (или null). */
+function getInitPrompt(windowId) {
+  return initPrompts.get(windowId) || null;
+}
+
 function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
   return new Promise((resolve) => {
     if (!win || win.isDestroyed()) return resolve(false);
@@ -92,6 +101,9 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
           "deepseek",
         );
         const combined = base + "\n\n" + extraPrompt;
+        try {
+          initPrompts.set(win.id, combined);
+        } catch (_) {}
         win.webContents.send("initial-prompt", combined);
         sent = true;
         console.log(
@@ -108,15 +120,24 @@ function sendInitPromptWhenReady(win, projectDir, extraPrompt, timeoutMs) {
         resolve(false);
       }
     };
-    win.webContents.once("did-finish-load", () => setTimeout(send, 1200));
-    // На случай, если страница уже загрузилась до подписки
-    setTimeout(send, 3000);
-    // Абсолютный таймаут
+    // DeepSeek на холодном старте монтирует React долго — ждём дольше.
+    win.webContents.once("did-finish-load", () => setTimeout(send, 3000));
+    // Повторная попытка, если первая не сработала (окно догрузилось позже).
+    setTimeout(send, 8000);
+    setTimeout(send, 15000);
+    // Абсолютный таймаут: ждём до 3 минут, пока окно будет готово.
     setTimeout(
       () => {
-        if (!sent) resolve(false);
+        if (!sent) {
+          console.warn(
+            "[Subagent] initial-prompt не отправлен за " +
+              Math.round(Math.min(timeoutMs, 180000) / 1000) +
+              "с — окно, возможно, не готово",
+          );
+          resolve(false);
+        }
       },
-      Math.min(timeoutMs, 60000),
+      Math.min(timeoutMs, 180000),
     );
   });
 }
@@ -381,4 +402,5 @@ module.exports = {
   runAgent,
   onSubagentResponse,
   hasRunningSubagent,
+  getInitPrompt,
 };
diff --git a/src/main/token-interceptor-inject.js b/src/main/token-interceptor-inject.js
index 46d0211..391e28d 100644
--- a/src/main/token-interceptor-inject.js
+++ b/src/main/token-interceptor-inject.js
@@ -123,6 +123,32 @@ function buildTokenInterceptorScript() {
         return typeof url === "string" && url.indexOf(COMPLETION) !== -1;
       }
 
+      // Rate-limit: 429 или бизнес-код 40029 (IP_ACCESS_RESTRICTED).
+      function publishRateLimit(extra) {
+        try {
+          window.postMessage(
+            {
+              __cuckoo: "rate-limit",
+              status: (extra && extra.status) || 0,
+              retryAfterSec: (extra && extra.retryAfterSec) || 0,
+              ts: Date.now(),
+            },
+            "*",
+          );
+        } catch (e) {}
+      }
+
+      // Проверить тело ответа/ошибки на 40029 и вызвать publishRateLimit.
+      function checkBodyForRateLimit(body) {
+        if (!body || typeof body !== "string") return;
+        if (
+          body.indexOf("40029") === -1 &&
+          body.indexOf("IP_ACCESS_RESTRICTED") === -1
+        )
+          return;
+        publishRateLimit({ status: 200 });
+      }
+
       // ---- патч fetch ----
       try {
         var originalFetch = window.fetch;
@@ -132,6 +158,18 @@ function buildTokenInterceptorScript() {
           var p = originalFetch.apply(this, arguments);
           if (!isCompletion(url)) return p;
           return p.then(function (response) {
+            // HTTP 429 — rate limit.
+            try {
+              if (response && response.status === 429) {
+                var ra = 0;
+                try {
+                  ra =
+                    parseInt(response.headers.get("retry-after") || "0", 10) ||
+                    0;
+                } catch (e) {}
+                publishRateLimit({ status: 429, retryAfterSec: ra });
+              }
+            } catch (e) {}
             try {
               var clone = response.clone();
               clone
@@ -139,6 +177,7 @@ function buildTokenInterceptorScript() {
                 .then(function (body) {
                   var u = parseSseText(body);
                   if (u) publish(u);
+                  checkBodyForRateLimit(body);
                 })
                 .catch(function () {});
             } catch (e) {}
@@ -164,8 +203,10 @@ function buildTokenInterceptorScript() {
             if (isCompletion(this.__cuckooUrl || "")) {
               this.addEventListener("load", function () {
                 try {
+                  if (self.status === 429) publishRateLimit({ status: 429 });
                   var u = parseSseText(self.responseText);
                   if (u) publish(u);
+                  checkBodyForRateLimit(self.responseText);
                 } catch (e) {}
               });
             }
diff --git a/src/preload/api.js b/src/preload/api.js
index d225835..ed85fea 100644
--- a/src/preload/api.js
+++ b/src/preload/api.js
@@ -52,6 +52,8 @@ let electronAPI = {
   telegramTest: () => ipcRenderer.invoke("telegram-test"),
   telegramNotifyAI: (text) =>
     ipcRenderer.invoke("telegram-notify-ai", { text }),
+  telegramRateLimit: (info) =>
+    ipcRenderer.invoke("telegram-rate-limit", info || {}),
   telegramTypingStart: () => ipcRenderer.invoke("telegram-typing-start"),
   telegramTypingStop: () => ipcRenderer.invoke("telegram-typing-stop"),
   telegramWhisperStatus: (opts) =>
diff --git a/src/preload/dom/chat-input.js b/src/preload/dom/chat-input.js
index 4718e5a..7832da4 100644
--- a/src/preload/dom/chat-input.js
+++ b/src/preload/dom/chat-input.js
@@ -290,13 +290,17 @@ function sendInitialPromptToInput() {
  */
 function waitForInitialPromptAndSend() {
   let attempts = 0;
-  const maxAttempts = 30;
+  // Ждём появления поля ввода до 90 секунд (180 * 500мс). DeepSeek на холодном
+  // старте может долго монтировать React-приложение — 15с не хватало, и окно
+  // субагента висело пустым без промпта.
+  const maxAttempts = 180;
 
   const checkInterval = setInterval(() => {
     attempts++;
     if (attempts > maxAttempts) {
       clearInterval(checkInterval);
-      state.pendingInitialPrompt = false;
+      // НЕ сбрасываем pendingInitialPrompt: пусть следующий вызов
+      // (например, по новому initial-prompt или ручному ретраю) попробует снова.
       return;
     }
 
@@ -374,6 +378,32 @@ function fallbackSend(provider, input) {
     console.log("[Cookie Code] 已通过 Enter 键触发发送 (未找到发送按钮)");
   }
 }
+/**
+ * Применить initial-prompt: сохранить и отправить в поле ввода (или ждать его).
+ * @param {string} content
+ */
+let _initialPromptApplied = false;
+function applyInitialPrompt(content) {
+  // Антидубль: push и pull могут прийти оба — применяем только один раз.
+  if (_initialPromptApplied) return;
+  const text = content || "";
+  if (!text) return;
+  _initialPromptApplied = true;
+  state.initialPromptContent = text;
+  state.pendingInitialPrompt = true;
+  if (state.pendingInitialPrompt && state.initialPromptContent) {
+    try {
+      const input = findInputArea();
+      if (input) {
+        sendInitialPromptToInput();
+      } else {
+        // Ждём появления поля ввода.
+        waitForInitialPromptAndSend();
+      }
+    } catch (e) {}
+  }
+}
+
 /**
  * 注册主进程消息监听(initial-prompt)
  * 与原 preload.js 顶层注册时机一致:preload 入口加载时同步调用。
@@ -381,22 +411,39 @@ function fallbackSend(provider, input) {
 function registerIpcListeners() {
   // 监听主进程发送的初始提示
   ipcRenderer.on("initial-prompt", (_event, content) => {
-    state.initialPromptContent = content || "";
-    state.pendingInitialPrompt = true;
-    // 如果当前已有新的空会话输入框,立即发送
-    if (state.pendingInitialPrompt && state.initialPromptContent) {
-      try {
-        const input = findInputArea();
-        if (input) {
-          sendInitialPromptToInput();
-        } else {
-          // 等待输入框出现
-          waitForInitialPromptAndSend();
-        }
-      } catch (e) {}
-    }
+    applyInitialPrompt(content);
   });
 
+  // Pull-модель: если push initial-prompt потерялся (окно субагента долго
+  // грузилось и не успело подписаться на канал), через 20с сами запросим его
+  // у main-процесса. Актуально только для окна-субагента.
+  try {
+    const isSubagent =
+      (typeof process !== "undefined" &&
+        Array.isArray(process.argv) &&
+        process.argv.some(
+          (a) => typeof a === "string" && a.startsWith("--cuckoo-subagent="),
+        )) ||
+      false;
+    if (isSubagent) {
+      setTimeout(() => {
+        if (state.initialPromptContent) return; // уже получили
+        try {
+          Promise.resolve(ipcRenderer.invoke("subagent-get-init-prompt"))
+            .then((res) => {
+              if (res && res.success && res.text) {
+                console.log(
+                  "[Cookie Code] initial-prompt получен по pull-запросу",
+                );
+                applyInitialPrompt(res.text);
+              }
+            })
+            .catch(() => {});
+        } catch (_) {}
+      }, 20000);
+    }
+  } catch (_) {}
+
   // Ответ из окна кастомного уведомления — сразу вставляем в чат и отправляем.
   ipcRenderer.on("cuckoo-notif-reply", (_event, text) => {
     const msg = String(text || "").trim();
diff --git a/src/preload/dom/subagent-watch.js b/src/preload/dom/subagent-watch.js
index 5a1a6d8..8b1f1d9 100644
--- a/src/preload/dom/subagent-watch.js
+++ b/src/preload/dom/subagent-watch.js
@@ -14,6 +14,7 @@
 const { ipcRenderer } = require("electron");
 const { getProviderByUrl } = require("../../../src/providers");
 const { getJsCodeBlocksFromMarkdown } = require("./js-detector");
+const chatInput = require("./chat-input");
 
 const STABLE_MS = 2500; // тишина, после которой считаем ответ финальным
 const POLL_MS = 700;
@@ -82,6 +83,98 @@ function showProjectDir(cfg) {
   }
 }
 
+// ===== Детект DSML/XML-мусора вместо cuckoo-блоков =====
+// AI иногда выдаёт сырые XML-вызовы (<||DSML||invoke>, ,
+// ) вместо cuckoo-кодблока. Такой ответ нельзя считать
+// финальным — надо поругать модель и дождаться корректного формата.
+const DSML_PATTERNS = [
+  /<\s*||DSML||/i,
+  /<\s*(?:[\w-]+:)?invoke\s+name=/i,
+  /<\s*\/\s*(?:[\w-]+:)?invoke\s*>/i,
+  /<\s*(?:[\w-]+:)?parameter\s+name=/i,
+  /<\s*(?:[\w-]+:)?tool_calls?\b/i,
+];
+
+/**
+ * Есть ли DSML/XML-мусор (сломанный tool-call вместо cuckoo-блока).
+ * Проверяем и видимый текст (textContent), и HTML (innerHTML): React/markdown
+ * может отрендерить  как элемент — тогда в textContent останется
+ * только "name=...", а тег будет лишь в innerHTML.
+ * @param {string} text  — md.textContent
+ * @param {string} [html] — md.innerHTML
+ * @returns {boolean}
+ */
+function detectDsmlGarbage(text, html) {
+  const a = typeof text === "string" ? text : "";
+  const b = typeof html === "string" ? html : "";
+  const hay = a + "\n" + b;
+  if (!hay.trim()) return false;
+  // 1) Явные маркеры DSML в любом виде.
+  if (/DSML/i.test(hay)) return true;
+  // 2) XML-подобные вызовы инструментов в тексте или HTML.
+  if (DSML_PATTERNS.some((re) => re.test(hay))) return true;
+  // 3) Текст без "<", но с характерной парой invoke/parameter + name=
+  //    (теги съедены markdown-рендером): "invoke name=" / "parameter name=".
+  if (/(?:^|[\s>"'])(?:invoke|parameter|tool_calls?)\s+name\s*=/i.test(hay))
+    return true;
+  return false;
+}
+
+/**
+ * Поругать AI за DSML/XML-формат и НЕ завершать ответ.
+ * Ограничено MAX_HINTS попытками, чтобы не зациклиться навсегда.
+ * @param {string} snapshot — текущий текст ответа (для антидубля)
+ */
+const MAX_DSML_HINTS = 3;
+let dsmlHintCount = 0;
+let lastDsmlHintSnapshot = "";
+function handleDsmlGarbage(snapshot) {
+  // Один и тот же сломанный ответ не ругаем повторно.
+  if (snapshot === lastDsmlHintSnapshot) return;
+  if (dsmlHintCount >= MAX_DSML_HINTS) {
+    console.warn(
+      "[Cookie Code][Subagent] ⚠️ DSML-мусор: исчерпан лимит подсказок (" +
+        MAX_DSML_HINTS +
+        "), ждём без отправки новых",
+    );
+    lastDsmlHintSnapshot = snapshot;
+    return;
+  }
+  dsmlHintCount++;
+  lastDsmlHintSnapshot = snapshot;
+  const BT = String.fromCharCode(96);
+  const fence = BT + BT + BT;
+  console.log(
+    "[Cookie Code][Subagent] ⚠️ Обнаружен DSML/XML-мусор (подсказка " +
+      dsmlHintCount +
+      "/" +
+      MAX_DSML_HINTS +
+      "), просим использовать cuckoo-блок",
+  );
+  try {
+    chatInput.sendMessageToChat(
+      "❌ INVALID TOOL CALL FORMAT DETECTED: you used XML/DSML " +
+        "(<||DSML||invoke>, , ), but you MUST use a " +
+        fence +
+        "cuckoo" +
+        fence +
+        " code block. " +
+        "DO NOT write XML tags. Call the tool via a " +
+        fence +
+        "cuckoo" +
+        fence +
+        " code block with JS code (await tool_name({...})). " +
+        "This is critical: the XML format is NOT executed.",
+      "DSML format warning",
+    );
+  } catch (err) {
+    console.error(
+      "[Cookie Code][Subagent] не удалось отправить DSML-подсказку:",
+      err && err.message,
+    );
+  }
+}
+
 /**
  * Если окно — субагент, запустить цикл наблюдения за финальным ответом.
  */
@@ -142,7 +235,22 @@ function initSubagentWatch() {
       // Текст не менялся — проверяем, устоялся ли.
       if (Date.now() - lastChangeAt < STABLE_MS) return;
 
-      // Устоявшийся текст. Проверяем, есть ли в нём cuckoo-блоки.
+      // Устоявшийся текст. СНАЧАЛА проверяем DSML/XML-мусор (по тексту и HTML),
+      // ДО анализа блоков: мусор может прийти и внутри 
, тогда обычная
+      // ветка "tool-блоки" его не поймает и ответ завершится.
+      const rawHtml = (got.md && got.md.innerHTML) || "";
+      if (detectDsmlGarbage(snapshot, rawHtml)) {
+        console.log(
+          "[Cookie Code][Subagent] DSML-детект: мусор найден. text[0..120]=" +
+            JSON.stringify(snapshot.slice(0, 120)) +
+            " html[0..160]=" +
+            JSON.stringify(rawHtml.slice(0, 160)),
+        );
+        handleDsmlGarbage(snapshot);
+        return;
+      }
+
+      // Проверяем, есть ли в нём cuckoo-блоки.
       const blocks = (() => {
         try {
           return getJsCodeBlocksFromMarkdown(got.md) || [];
diff --git a/src/preload/dom/token-interceptor.js b/src/preload/dom/token-interceptor.js
index e8ef5e1..6573496 100644
--- a/src/preload/dom/token-interceptor.js
+++ b/src/preload/dom/token-interceptor.js
@@ -21,11 +21,11 @@
  * не бросает исключение наружу, а просто не применяется.
  */
 
-const { safe } = require('./safe');
+const { safe } = require("./safe");
 
-const COMPLETION_URL_PART = '/api/v0/chat/completion';
-const EVENT_NAME = 'cuckoo:token-update';
-const LOG_PREFIX = '[Cookie Code][tokens]';
+const COMPLETION_URL_PART = "/api/v0/chat/completion";
+const EVENT_NAME = "cuckoo:token-update";
+const LOG_PREFIX = "[Cookie Code][tokens]";
 
 // Последнее известное накопленное значение (по всему приложению).
 let lastTotal = 0;
@@ -39,7 +39,7 @@ let installed = false;
  * @returns {{total:number, prompt:number, completion:number, source:string}|null}
  */
 function extractUsage(obj) {
-  if (!obj || typeof obj !== 'object') return null;
+  if (!obj || typeof obj !== "object") return null;
 
   // === Реальный формат DeepSeek (SSE completion) ===
   // Кадр-снимок (WIP): {"v":{"response":{"accumulated_token_usage":50, ...}}}
@@ -49,35 +49,52 @@ function extractUsage(obj) {
   if (Array.isArray(obj.v)) {
     for (let i = obj.v.length - 1; i >= 0; i--) {
       const op = obj.v[i];
-      if (op && typeof op === 'object' && op.p === 'accumulated_token_usage') {
-        const n = pickNumber(op, ['v', 'value']);
-        if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' };
+      if (op && typeof op === "object" && op.p === "accumulated_token_usage") {
+        const n = pickNumber(op, ["v", "value"]);
+        if (n != null)
+          return { total: n, prompt: 0, completion: 0, source: "accumulated" };
       }
     }
   }
 
   // 1b) Кадр-снимок: v.response.accumulated_token_usage.
-  const snapResp = obj.v && typeof obj.v === 'object' && !Array.isArray(obj.v) ? obj.v.response : null;
-  if (snapResp && typeof snapResp === 'object') {
-    const n = pickNumber(snapResp, ['accumulated_token_usage']);
-    if (n != null) return { total: n, prompt: 0, completion: 0, source: 'accumulated' };
+  const snapResp =
+    obj.v && typeof obj.v === "object" && !Array.isArray(obj.v)
+      ? obj.v.response
+      : null;
+  if (snapResp && typeof snapResp === "object") {
+    const n = pickNumber(snapResp, ["accumulated_token_usage"]);
+    if (n != null)
+      return { total: n, prompt: 0, completion: 0, source: "accumulated" };
   }
 
   // 2) accumulated_token_usage в корне (число либо объект) — на случай смены формата.
   const acc = obj.accumulated_token_usage;
-  if (typeof acc === 'number' && Number.isFinite(acc)) {
-    return { total: acc, prompt: 0, completion: 0, source: 'accumulated' };
+  if (typeof acc === "number" && Number.isFinite(acc)) {
+    return { total: acc, prompt: 0, completion: 0, source: "accumulated" };
   }
-  if (acc && typeof acc === 'object') {
-    const t = pickNumber(acc, ['total_tokens', 'total', 'value']);
-    if (t != null) return { total: t, prompt: pickNumber(acc, ['prompt_tokens']) || 0, completion: pickNumber(acc, ['completion_tokens']) || 0, source: 'accumulated' };
+  if (acc && typeof acc === "object") {
+    const t = pickNumber(acc, ["total_tokens", "total", "value"]);
+    if (t != null)
+      return {
+        total: t,
+        prompt: pickNumber(acc, ["prompt_tokens"]) || 0,
+        completion: pickNumber(acc, ["completion_tokens"]) || 0,
+        source: "accumulated",
+      };
   }
 
   // 3) usage.total_tokens — резервный источник (OpenAI-подобный формат).
   const usage = obj.usage;
-  if (usage && typeof usage === 'object') {
-    const t = pickNumber(usage, ['total_tokens', 'total']);
-    if (t != null) return { total: t, prompt: pickNumber(usage, ['prompt_tokens']) || 0, completion: pickNumber(usage, ['completion_tokens']) || 0, source: 'usage' };
+  if (usage && typeof usage === "object") {
+    const t = pickNumber(usage, ["total_tokens", "total"]);
+    if (t != null)
+      return {
+        total: t,
+        prompt: pickNumber(usage, ["prompt_tokens"]) || 0,
+        completion: pickNumber(usage, ["completion_tokens"]) || 0,
+        source: "usage",
+      };
   }
 
   return null;
@@ -92,7 +109,7 @@ function extractUsage(obj) {
 function pickNumber(obj, keys) {
   for (const k of keys) {
     const v = obj[k];
-    if (typeof v === 'number' && Number.isFinite(v)) return v;
+    if (typeof v === "number" && Number.isFinite(v)) return v;
   }
   return null;
 }
@@ -113,12 +130,30 @@ function publish(usage) {
   // Нулевую дельту (стартовый кадр-снимок) не рассылаем — полезны только реальные приросты.
   if (delta === 0) return;
   try {
-    window.dispatchEvent(new CustomEvent(EVENT_NAME, {
-      detail: { total: usage.total, delta, prompt: usage.prompt, completion: usage.completion, source: usage.source },
-    }));
-  } catch (_) { /* CustomEvent недоступен — молча */ }
+    window.dispatchEvent(
+      new CustomEvent(EVENT_NAME, {
+        detail: {
+          total: usage.total,
+          delta,
+          prompt: usage.prompt,
+          completion: usage.completion,
+          source: usage.source,
+        },
+      }),
+    );
+  } catch (_) {
+    /* CustomEvent недоступен — молча */
+  }
   try {
-    console.log(LOG_PREFIX + ' server tokens: total=' + usage.total + ' delta=' + delta + ' src=' + usage.source);
+    console.log(
+      LOG_PREFIX +
+        " server tokens: total=" +
+        usage.total +
+        " delta=" +
+        delta +
+        " src=" +
+        usage.source,
+    );
   } catch (_) {}
 }
 
@@ -129,14 +164,14 @@ function publish(usage) {
  * @returns {{total:number, prompt:number, completion:number, source:string}|null}
  */
 function parseSseText(text) {
-  if (!text || typeof text !== 'string') return null;
+  if (!text || typeof text !== "string") return null;
   let found = null;
   const lines = text.split(/\r?\n/);
   for (const line of lines) {
     const trimmed = line.trim();
-    if (!trimmed || !trimmed.startsWith('data:')) continue;
+    if (!trimmed || !trimmed.startsWith("data:")) continue;
     const payload = trimmed.slice(5).trim();
-    if (!payload || payload === '[DONE]') continue;
+    if (!payload || payload === "[DONE]") continue;
     const parsed = safeJson(payload);
     if (!parsed) continue;
     const usage = extractUsage(parsed);
@@ -151,7 +186,11 @@ function parseSseText(text) {
  * @returns {object|null}
  */
 function safeJson(s) {
-  try { return JSON.parse(s); } catch (_) { return null; }
+  try {
+    return JSON.parse(s);
+  } catch (_) {
+    return null;
+  }
 }
 
 /**
@@ -160,10 +199,34 @@ function safeJson(s) {
  * @returns {boolean}
  */
 function isCompletionUrl(url) {
-  if (!url || typeof url !== 'string') return false;
+  if (!url || typeof url !== "string") return false;
   return url.indexOf(COMPLETION_URL_PART) !== -1;
 }
 
+// Антиспам: не чаще одного уведомления о rate-limit в 60 секунд.
+let lastRateLimitNotifyTs = 0;
+const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 60 * 1000;
+
+/**
+ * Уведомить Telegram о превышении rate-limit (429 / IP_ACCESS_RESTRICTED).
+ * @param {{status?:number, retryAfterSec?:number}} d
+ */
+function notifyRateLimit(d) {
+  try {
+    const now = Date.now();
+    if (now - lastRateLimitNotifyTs < RATE_LIMIT_NOTIFY_COOLDOWN_MS) return;
+    lastRateLimitNotifyTs = now;
+    const api = typeof window !== "undefined" ? window.electronAPI : null;
+    if (!api || typeof api.telegramRateLimit !== "function") return;
+    api
+      .telegramRateLimit({
+        status: (d && d.status) || 0,
+        retryAfterSec: (d && d.retryAfterSec) || 0,
+      })
+      .catch(() => {});
+  } catch (_) {}
+}
+
 /**
  * Установить приём серверных токенов.
  *
@@ -183,14 +246,28 @@ function install() {
   installed = true;
 
   // Основной канал: postMessage из main world.
-  safe('token-interceptor.listenMessage', () => {
-    window.addEventListener('message', (e) => {
+  safe("token-interceptor.listenMessage", () => {
+    window.addEventListener("message", (e) => {
       try {
         const d = e && e.data;
-        if (!d || d.__cuckoo !== 'token-update') return;
-        if (typeof d.total !== 'number' || !Number.isFinite(d.total)) return;
-        const delta = typeof d.delta === 'number' ? d.delta : 0;
-        const usage = { total: d.total, delta, prompt: 0, completion: 0, source: d.source || 'accumulated' };
+        if (!d) return;
+
+        // Rate-limit (429 / IP_ACCESS_RESTRICTED=40029) -> уведомление в Telegram.
+        if (d.__cuckoo === "rate-limit") {
+          notifyRateLimit(d);
+          return;
+        }
+
+        if (d.__cuckoo !== "token-update") return;
+        if (typeof d.total !== "number" || !Number.isFinite(d.total)) return;
+        const delta = typeof d.delta === "number" ? d.delta : 0;
+        const usage = {
+          total: d.total,
+          delta,
+          prompt: 0,
+          completion: 0,
+          source: d.source || "accumulated",
+        };
         // publish ожидает total и сам считает дельту от lastTotal; чтобы не
         // считать дважды, синхронизируем lastTotal из main world.
         lastTotal = d.total - delta;
@@ -200,8 +277,8 @@ function install() {
   });
 
   // Резерв: патчи в самом preload (сработают только без contextIsolation).
-  safe('token-interceptor.patchFetch', () => patchFetch());
-  safe('token-interceptor.patchXhr', () => patchXhr());
+  safe("token-interceptor.patchFetch", () => patchFetch());
+  safe("token-interceptor.patchXhr", () => patchXhr());
 }
 
 /**
@@ -209,22 +286,26 @@ function install() {
  * клонируем ответ и читаем тело как текст (SSE).
  */
 function patchFetch() {
-  if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
+  if (typeof window === "undefined" || typeof window.fetch !== "function")
+    return;
   const originalFetch = window.fetch;
 
   window.fetch = function patchedFetch(input, init) {
-    const url = typeof input === 'string' ? input : (input && input.url) || '';
+    const url = typeof input === "string" ? input : (input && input.url) || "";
     const p = originalFetch.apply(this, arguments);
     if (!isCompletionUrl(url)) return p;
 
     return p.then((response) => {
-      safe('token-interceptor.fetchRead', () => {
+      safe("token-interceptor.fetchRead", () => {
         // Клонируем, чтобы не «съесть» тело у вызывающего кода.
         const clone = response.clone();
-        clone.text().then((body) => {
-          const usage = parseSseText(body);
-          if (usage) publish(usage);
-        }).catch(() => {});
+        clone
+          .text()
+          .then((body) => {
+            const usage = parseSseText(body);
+            if (usage) publish(usage);
+          })
+          .catch(() => {});
       });
       return response;
     });
@@ -235,22 +316,28 @@ function patchFetch() {
  * Патч XMLHttpRequest.open/send: запоминаем URL, по завершении читаем responseText.
  */
 function patchXhr() {
-  if (typeof window === 'undefined' || typeof window.XMLHttpRequest !== 'function') return;
+  if (
+    typeof window === "undefined" ||
+    typeof window.XMLHttpRequest !== "function"
+  )
+    return;
   const XHR = window.XMLHttpRequest;
   const originalOpen = XHR.prototype.open;
   const originalSend = XHR.prototype.send;
 
   XHR.prototype.open = function patchedOpen(method, url) {
-    try { this.__cuckooUrl = url; } catch (_) {}
+    try {
+      this.__cuckooUrl = url;
+    } catch (_) {}
     return originalOpen.apply(this, arguments);
   };
 
   XHR.prototype.send = function patchedSend() {
     try {
-      const url = this.__cuckooUrl || '';
+      const url = this.__cuckooUrl || "";
       if (isCompletionUrl(url)) {
-        this.addEventListener('load', () => {
-          safe('token-interceptor.xhrRead', () => {
+        this.addEventListener("load", () => {
+          safe("token-interceptor.xhrRead", () => {
             const body = this.responseText;
             const usage = parseSseText(body);
             if (usage) publish(usage);
@@ -269,8 +356,12 @@ module.exports = {
   _parseSseText: parseSseText,
   _isCompletionUrl: isCompletionUrl,
   _publish: publish,
-  _resetTotal: () => { lastTotal = 0; },
-  _setLastTotal: (v) => { lastTotal = v; },
+  _resetTotal: () => {
+    lastTotal = 0;
+  },
+  _setLastTotal: (v) => {
+    lastTotal = v;
+  },
   _getLastTotal: () => lastTotal,
   EVENT_NAME,
 };