zames_pro 2.69.0 → 2.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.70.0] - 2026-10-08
11
+
12
+ ### Added
13
+
14
+ - **browser:** centralize DeepSeek UI constants and add a UI health probe
15
+
10
16
  ## [2.69.0] - 2026-10-08
11
17
 
12
18
  ### Changed
package/dist/browser.js CHANGED
@@ -1,11 +1,18 @@
1
1
  import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, GENERATION_ERR_RE, INCOMPLETE_STATUS_RE, ANSWER_URL_RE, } from './deepseek-ui.js';
5
+ import { probeUiHealth, } from './ui-health.js';
4
6
  import path from 'path';
5
7
  import os from 'os';
6
8
  import fs from 'fs/promises';
7
9
  import { theme } from './theme.js';
8
10
  import { translate, DEFAULT_LOCALE } from './i18n.js';
11
+ // Re-export the DeepSeek UI regexes other modules/tests import from browser.ts.
12
+ // The definitions live in deepseek-ui.ts; re-exporting the imported bindings
13
+ // here preserves the existing public API (test/*.test.ts import them from
14
+ // browser.ts).
15
+ export { STOP_NAME_RE, CONTINUE_NAME_RE };
9
16
  // Muted terminal input for passwords. We do NOT use readline here: readline
10
17
  // echoes the typed text through its own internal _writeToOutput, which cannot
11
18
  // be reliably overridden from outside (a real bug: the login prompt swallowed
@@ -86,14 +93,6 @@ export function askPassword(question) {
86
93
  });
87
94
  }
88
95
  const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
89
- const CHAT_URL = 'https://chat.deepseek.com/';
90
- // The "New chat" control is NOT a <button>/<a> on the current DeepSeek build
91
- // (it is a <div>), so matching only button/a missed it, the click timed out
92
- // and newChat() silently no-opped. Match any clickable-ish element whose text
93
- // is EXACTLY the new-chat label (an anchored regex keeps a big outer container
94
- // from matching its long descendant text).
95
- const NEW_CHAT_SELECTOR = 'button, a, [role="button"], div, span';
96
- const NEW_CHAT_TEXT_RE = /^\s*(new chat|новый чат|новый диалог)\s*$/i;
97
96
  // Cache of the sanitized (non-Headless) User-Agent, keyed by the browser
98
97
  // engine path. On the FIRST headless run we detect the real UA, strip the
99
98
  // "Headless" marker and store it here; later runs pass it straight to
@@ -165,119 +164,6 @@ export function sanitizeHeadlessUA(ua) {
165
164
  // Written after a successful sign-in so the next launch knows a session was
166
165
  // stored in the persistent profile (used by /doctor and diagnostics).
167
166
  const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
168
- // DeepSeek sign-in form. The login page is served on the same origin and
169
- // swaps in a password field; the exact classes change, so we match loosely on
170
- // input types and the known placeholder/autocomplete attributes.
171
- const PASSWORD_SELECTORS = [
172
- 'input[type="password"]',
173
- 'input[autocomplete="current-password"]',
174
- 'input[autocomplete="new-password"]',
175
- ];
176
- const LOGIN_SELECTORS = [
177
- 'input[type="email"]',
178
- 'input[type="tel"]',
179
- 'input[name="email"]',
180
- 'input[name="phone"]',
181
- 'input[name="username"]',
182
- 'input[placeholder*="email" i]',
183
- 'input[placeholder*="phone" i]',
184
- 'input[placeholder*="телефон" i]',
185
- 'input[placeholder*="почт" i]',
186
- 'input[autocomplete="username"]',
187
- 'input[autocomplete="email"]',
188
- ];
189
- const LOGIN_SUBMIT_SELECTORS = [
190
- // DeepSeek's real button: a div[role=button] with these classes and a
191
- // <span class="ds-button__content">Log in</span> inside.
192
- 'div[role="button"].ds-button--primary.ds-button--filled',
193
- 'div[role="button"].ds-button--primary',
194
- 'button[type="submit"]',
195
- 'button:has-text("Log in")',
196
- 'button:has-text("Sign in")',
197
- 'button:has-text("Войти")',
198
- 'div[role="button"]:has-text("Log in")',
199
- 'div[role="button"]:has-text("Sign in")',
200
- 'div[role="button"]:has-text("Войти")',
201
- ];
202
- const INPUT_SELECTORS = [
203
- 'textarea',
204
- 'div[contenteditable="true"]',
205
- '[role="textbox"]',
206
- ];
207
- const ANSWER_SELECTORS = [
208
- 'div.ds-assistant-message-main-content',
209
- 'div[class*="ds-assistant-message-main-content"]',
210
- 'div[class*="ds-markdown"]',
211
- 'div[class*="markdown"]',
212
- ];
213
- const SEND_SELECTORS = [
214
- 'div[role="button"].ds-button--primary.ds-button--circle',
215
- 'div[role="button"].ds-button--primary.ds-button--filled',
216
- 'button[type="submit"]',
217
- 'button[aria-label*="send" i]',
218
- 'button[aria-label*="отправ" i]',
219
- ];
220
- // IMPORTANT: you MUST NOT add the generic 'div[role="button"][class*="ds-button--primary"]'
221
- // here — it matches the send button, which is always visible, and then
222
- // _isGenerating() always returns true, so the answer is never considered ready.
223
- const STOP_SELECTORS = [
224
- 'div[role="button"][aria-label*="stop" i]',
225
- 'div[role="button"][aria-label*="останов" i]',
226
- 'button:has-text("Stop")',
227
- 'button:has-text("Остановить")',
228
- 'button[aria-label*="Stop" i]',
229
- ];
230
- // Accessible name of the Stop button, for a TRUSTED Playwright click. It is a
231
- // bare stop word (optionally with a short suffix), anchored and length-limited
232
- // so a "Stop" inside rendered prose is never clicked.
233
- export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
234
- // DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle
235
- // on, the server caps the THINK phase: the reasoning stops mid-way and the UI
236
- // offers a Continue button (this is NOT the same as a truncated turn with
237
- // generation_err — there the answer failed; here the model simply paused).
238
- // The agent must click it so the reasoning/answer keeps flowing, otherwise the
239
- // turn sits idle until the operator presses Continue by hand.
240
- //
241
- // The button is found by Playwright's accessible name (getByRole) with an
242
- // EXACT match, falling back to a raw DOM scan. The class names change between
243
- // builds, so we do not rely on them.
244
- // Reasoning (Deep thinking) mode uses a LONGER label: the server pauses the
245
- // THINK phase and offers «Продолжить размышление» / «Continue thinking», NOT a
246
- // bare «Continue». The old exact list missed those, so with thinking ON the
247
- // button was never found — neither for the auto-click NOR for the "is the turn
248
- // paused?" guard — and the operator had to press it by hand.
249
- //
250
- // We match the accessible name with a REGEX: a bare continue word OR the same
251
- // word followed by a short reasoning/answer suffix. It is anchored and
252
- // length-limited, so a "Continue" inside rendered prose is never clicked (the
253
- // old non-exact `getByRole` matched any button CONTAINING the word).
254
- export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
255
- // DeepSeek UI service statuses that are NOT the model's answer.
256
- // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
257
- const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
258
- // DeepSeek's answer when the rate limit is exceeded.
259
- //
260
- // IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
261
- // part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
262
- // Try again later.", "Service unavailable. Try again later."), so matching it
263
- // here classified every server hiccup as a RATE LIMIT and sent the agent into
264
- // a 5-minute wait (and, after the finish-loop stopped reading toasts every
265
- // tick, looked like "the agent hung"). The rate limit is recognized by its
266
- // specific wording only.
267
- const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
268
- // Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
269
- // rate limit, these usually clear in seconds, so we retry quickly instead of
270
- // waiting minutes. "Server busy", 503, "temporarily unavailable", etc.
271
- const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
272
- // DeepSeek turns a generation that FAILED mid-way into a truncated turn
273
- // (`quasi_status: INCOMPLETE` + `finish_reason: generation_err`) and shows a
274
- // "Continue" button in the UI. The failure is invisible to the answer text:
275
- // the DOM keeps the partial answer (often the previous one), so the finish
276
- // loop used to wait out the whole timeout and throw ds.send_no_new_answer.
277
- // The SSE body IS available (net-capture keeps it as `_netCapture`), so we
278
- // recognize the failed/truncated turn from it and retry instead of hanging.
279
- const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
280
- const INCOMPLETE_STATUS_RE = /"quasi_status","v":"INCOMPLETE"/i;
281
167
  export function isGenerationIncompleteText(text) {
282
168
  const t = String(text || '');
283
169
  return GENERATION_ERR_RE.test(t) || INCOMPLETE_STATUS_RE.test(t);
@@ -763,7 +649,7 @@ export class DeepSeekBrowser {
763
649
  // capture assignment to those URLs: a future endpoint returning a
764
650
  // `content`/`text`/`response` string must not clobber the answer and
765
651
  // look fresh (it would be returned as the final answer).
766
- const isAnswerUrl = /chat\/(completion|continue)/i.test(url);
652
+ const isAnswerUrl = ANSWER_URL_RE.test(url);
767
653
  const extracted = extractAnswer(body);
768
654
  if (extracted && isAnswerUrl) {
769
655
  this._netCapture = extracted;
@@ -1065,8 +951,8 @@ export class DeepSeekBrowser {
1065
951
  // Click exactly the one whose visible label is a login word, so we never
1066
952
  // hit "Log in with Google" or another primary button by mistake.
1067
953
  const precise = this.page
1068
- .locator('div[role="button"].ds-button--primary, button')
1069
- .filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
954
+ .locator(LOGIN_BUTTON_SELECTOR)
955
+ .filter({ hasText: LOGIN_NAME_RE });
1070
956
  const pc = await precise.count().catch(() => 0);
1071
957
  if (pc > 0) {
1072
958
  try {
@@ -1091,8 +977,9 @@ export class DeepSeekBrowser {
1091
977
  }
1092
978
  // Button inside the same form as the password field.
1093
979
  const formClicked = await this.page
1094
- .evaluate((pwdSel) => {
1095
- const p = document.querySelector(pwdSel);
980
+ .evaluate((opts) => {
981
+ const loginRe = new RegExp(opts.submitRe, 'i');
982
+ const p = document.querySelector(opts.pwdSel);
1096
983
  if (!p)
1097
984
  return false;
1098
985
  const form = p.closest('form');
@@ -1101,7 +988,7 @@ export class DeepSeekBrowser {
1101
988
  for (const b of btns) {
1102
989
  const txt = (b.textContent || '').trim().toLowerCase();
1103
990
  const aria = (b.getAttribute('aria-label') || '').toLowerCase();
1104
- if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
991
+ if (loginRe.test(txt + ' ' + aria)) {
1105
992
  b.click();
1106
993
  return true;
1107
994
  }
@@ -1116,7 +1003,10 @@ export class DeepSeekBrowser {
1116
1003
  return true;
1117
1004
  }
1118
1005
  return false;
1119
- }, PASSWORD_SELECTORS[0])
1006
+ }, {
1007
+ pwdSel: PASSWORD_SELECTORS[0],
1008
+ submitRe: LOGIN_FORM_SUBMIT_RE.source,
1009
+ })
1120
1010
  .catch(() => false);
1121
1011
  if (formClicked && (await tryLogin()))
1122
1012
  return true;
@@ -1267,16 +1157,18 @@ export class DeepSeekBrowser {
1267
1157
  // call". The DOM reader has no such self-comparison problem: before the
1268
1158
  // send the DOM shows the OLD answer, after it the NEW one.
1269
1159
  async _readLastAnswerTextDom() {
1270
- return await this.page.evaluate((sels) => {
1271
- // DeepSeek stores the model's reasoning in .ds-think-content blocks.
1160
+ return await this.page.evaluate((opts) => {
1161
+ const sels = opts.sels;
1162
+ // DeepSeek stores the model's reasoning in think blocks (THINK_CLASS_RE).
1272
1163
  // They are NOT the answer and must never be picked up as the answer
1273
1164
  // (otherwise the terminal would show the long reasoning). We also
1274
1165
  // prefer the first (most specific) selector with a hit.
1166
+ const think = new RegExp(opts.thinkRe, 'i');
1275
1167
  const inThink = (e) => {
1276
1168
  let n = e;
1277
1169
  while (n) {
1278
1170
  const cls = (n.className || '').toString();
1279
- if (/ds-think-content|thinking-content/i.test(cls))
1171
+ if (think.test(cls))
1280
1172
  return true;
1281
1173
  n = n.parentElement;
1282
1174
  }
@@ -1302,7 +1194,7 @@ export class DeepSeekBrowser {
1302
1194
  const out = el.innerText || el.textContent || '';
1303
1195
  const NL = String.fromCharCode(10);
1304
1196
  return out.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
1305
- }, ANSWER_SELECTORS);
1197
+ }, { sels: ANSWER_SELECTORS, thinkRe: THINK_CLASS_RE.source });
1306
1198
  }
1307
1199
  // We read ONLY visible toasts/notifications, not the whole page text.
1308
1200
  // Otherwise we catch "try again later" from service/hidden blocks and go
@@ -1358,11 +1250,12 @@ export class DeepSeekBrowser {
1358
1250
  if (explicit)
1359
1251
  return true;
1360
1252
  return await this.page
1361
- .evaluate(() => {
1253
+ .evaluate((btnRe) => {
1254
+ const re = new RegExp(btnRe, 'i');
1362
1255
  const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1363
1256
  for (const b of btns) {
1364
1257
  const cls = (b.className || '').toString();
1365
- if (!/ds-button--(circle|primary|filled)/i.test(cls))
1258
+ if (!re.test(cls))
1366
1259
  continue;
1367
1260
  const label = ((b.getAttribute('aria-label') || '') +
1368
1261
  ' ' +
@@ -1377,7 +1270,7 @@ export class DeepSeekBrowser {
1377
1270
  return true;
1378
1271
  }
1379
1272
  return false;
1380
- })
1273
+ }, BUTTON_CLASS_RE.source)
1381
1274
  .catch(() => false);
1382
1275
  }
1383
1276
  async _isGenerating() {
@@ -1391,16 +1284,16 @@ export class DeepSeekBrowser {
1391
1284
  // all we need to tell "the page grew / a new answer is streaming".
1392
1285
  async _chatSignal() {
1393
1286
  return await this.page
1394
- .evaluate(() => {
1395
- const nodes = document.querySelectorAll('[class*="ds-message"], [class*="chat-message"], .ds-markdown').length;
1287
+ .evaluate((opts) => {
1288
+ const nodes = document.querySelectorAll(opts.nodesSel).length;
1396
1289
  let lastLen = 0;
1397
- const answers = document.querySelectorAll('.ds-markdown');
1290
+ const answers = document.querySelectorAll(opts.mdSel);
1398
1291
  if (answers.length) {
1399
1292
  const lastEl = answers[answers.length - 1];
1400
1293
  lastLen = (lastEl.innerText || '').length;
1401
1294
  }
1402
1295
  return { nodes, lastLen };
1403
- })
1296
+ }, { nodesSel: CHAT_SIGNAL_NODES_SELECTOR, mdSel: MARKDOWN_SELECTOR })
1404
1297
  .catch(() => ({ nodes: 0, lastLen: 0 }));
1405
1298
  }
1406
1299
  // Cheap first probe for the Continue button. Runs ONE page.evaluate
@@ -1411,8 +1304,8 @@ export class DeepSeekBrowser {
1411
1304
  // HITS do we run the full scan to get a clickable Locator.
1412
1305
  async _continueVisibleCheap() {
1413
1306
  return await this.page
1414
- .evaluate(() => {
1415
- const re = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
1307
+ .evaluate((nameRe) => {
1308
+ const re = new RegExp(nameRe, 'i');
1416
1309
  const cands = Array.from(document.querySelectorAll('div[role="button"], button'));
1417
1310
  for (const e of cands) {
1418
1311
  const label = ((e.textContent || '') +
@@ -1430,7 +1323,7 @@ export class DeepSeekBrowser {
1430
1323
  return true;
1431
1324
  }
1432
1325
  return false;
1433
- })
1326
+ }, CONTINUE_NAME_RE.source)
1434
1327
  .catch(() => false);
1435
1328
  }
1436
1329
  // Is DeepSeek's "Continue" button currently visible? Used to BLOCK the
@@ -1608,28 +1501,43 @@ export class DeepSeekBrowser {
1608
1501
  // so we match by a loose regex on the visible text and read the state from
1609
1502
  // aria-pressed. We click ONLY when the state differs, so a send does not
1610
1503
  // flip a toggle the operator set by hand.
1504
+ // Find the toggle button whose label matches `labelRe`. Tries the class
1505
+ // anchor first, then the semantic fallbacks (see TOGGLE_SELECTORS) so a
1506
+ // renamed `ds-toggle-button` class does not silently disable the toggles.
1507
+ async _findToggle(labelRe) {
1508
+ for (const sel of TOGGLE_SELECTORS) {
1509
+ try {
1510
+ const btns = this.page.locator(sel);
1511
+ const count = await btns.count().catch(() => 0);
1512
+ for (let i = 0; i < count; i++) {
1513
+ const b = btns.nth(i);
1514
+ const txt = ((await b.textContent().catch(() => '')) || '').trim();
1515
+ if (!labelRe.test(txt))
1516
+ continue;
1517
+ const pressed = (await b.getAttribute('aria-pressed').catch(() => null)) === 'true';
1518
+ return { loc: b, pressed };
1519
+ }
1520
+ }
1521
+ catch { }
1522
+ }
1523
+ return null;
1524
+ }
1611
1525
  async _setToggle(labelRe, want) {
1612
1526
  try {
1613
- const btns = this.page.locator('.ds-toggle-button');
1614
- const count = await btns.count().catch(() => 0);
1615
- for (let i = 0; i < count; i++) {
1616
- const b = btns.nth(i);
1617
- const txt = ((await b.textContent().catch(() => '')) || '').trim();
1618
- if (!labelRe.test(txt))
1619
- continue;
1620
- const pressed = (await b.getAttribute('aria-pressed').catch(() => null)) === 'true';
1621
- if (pressed !== want) {
1622
- await b.click({ timeout: 2000 }).catch(() => { });
1623
- await this.page.waitForTimeout(150);
1624
- }
1527
+ const found = await this._findToggle(labelRe);
1528
+ if (!found)
1625
1529
  return;
1530
+ if (found.pressed !== want) {
1531
+ await found.loc.click({ timeout: 2000 }).catch(() => { });
1532
+ await this.page.waitForTimeout(150);
1626
1533
  }
1534
+ return;
1627
1535
  }
1628
1536
  catch { }
1629
1537
  }
1630
1538
  async _applyToggles() {
1631
- await this._setToggle(/глубок|deep\s*think/i, this.deepThinking);
1632
- await this._setToggle(/поиск|search/i, this.webSearch);
1539
+ await this._setToggle(DEEP_THINKING_RE, this.deepThinking);
1540
+ await this._setToggle(WEB_SEARCH_RE, this.webSearch);
1633
1541
  this._toggles = await this.getToggleStates();
1634
1542
  }
1635
1543
  // Synchronous snapshot of the last known toggle states, for the editor's
@@ -1652,22 +1560,16 @@ export class DeepSeekBrowser {
1652
1560
  async getToggleStates() {
1653
1561
  const read = async (labelRe, fallback) => {
1654
1562
  try {
1655
- const btns = this.page.locator('.ds-toggle-button');
1656
- const count = await btns.count().catch(() => 0);
1657
- for (let i = 0; i < count; i++) {
1658
- const b = btns.nth(i);
1659
- const txt = ((await b.textContent().catch(() => '')) || '').trim();
1660
- if (!labelRe.test(txt))
1661
- continue;
1662
- return ((await b.getAttribute('aria-pressed').catch(() => null)) === 'true');
1663
- }
1563
+ const found = await this._findToggle(labelRe);
1564
+ if (found)
1565
+ return found.pressed;
1664
1566
  }
1665
1567
  catch { }
1666
1568
  return fallback;
1667
1569
  };
1668
1570
  return {
1669
- deepThinking: await read(/глубок|deep\s*think/i, this.deepThinking),
1670
- webSearch: await read(/поиск|search/i, this.webSearch),
1571
+ deepThinking: await read(DEEP_THINKING_RE, this.deepThinking),
1572
+ webSearch: await read(WEB_SEARCH_RE, this.webSearch),
1671
1573
  };
1672
1574
  }
1673
1575
  // Stop the current generation. Used by Esc/Ctrl+C. Prefers a TRUSTED
@@ -1704,11 +1606,12 @@ export class DeepSeekBrowser {
1704
1606
  // 3) Raw DOM scan + the full pointer/mouse sequence (older builds without
1705
1607
  // a proper role, or a click intercepted by an overlay).
1706
1608
  const clicked = await this.page
1707
- .evaluate(() => {
1609
+ .evaluate((btnRe) => {
1610
+ const re = new RegExp(btnRe, 'i');
1708
1611
  const btns = Array.from(document.querySelectorAll('div[role="button"], button'));
1709
1612
  for (const b of btns) {
1710
1613
  const cls = (b.className || '').toString();
1711
- if (!/ds-button--(circle|primary|filled)/i.test(cls))
1614
+ if (!re.test(cls))
1712
1615
  continue;
1713
1616
  const svg = b.querySelector('svg');
1714
1617
  if (svg && svg.querySelector('rect')) {
@@ -1726,7 +1629,7 @@ export class DeepSeekBrowser {
1726
1629
  }
1727
1630
  }
1728
1631
  return false;
1729
- })
1632
+ }, BUTTON_CLASS_RE.source)
1730
1633
  .catch(() => false);
1731
1634
  if (clicked)
1732
1635
  return true;
@@ -1984,15 +1887,7 @@ export class DeepSeekBrowser {
1984
1887
  return failed;
1985
1888
  // Preferred path: click the attach button and let the file chooser event
1986
1889
  // carry the files. This is how a real user attaches a file and it reliably
1987
- // triggers DeepSeek's upload handler.
1988
- const ATTACH_SELECTORS = [
1989
- 'div[role="button"][aria-label*="attach" i]',
1990
- 'div[role="button"][aria-label*="влож" i]',
1991
- 'button[aria-label*="attach" i]',
1992
- 'button[aria-label*="влож" i]',
1993
- '[class*="upload"]',
1994
- '[class*="attach"]',
1995
- ];
1890
+ // triggers DeepSeek's upload handler. See ATTACH_SELECTORS in deepseek-ui.ts.
1996
1891
  for (const sel of ATTACH_SELECTORS) {
1997
1892
  const btn = this.page.locator(sel).last();
1998
1893
  try {
@@ -2678,15 +2573,23 @@ export class DeepSeekBrowser {
2678
2573
  });
2679
2574
  return { file: filePath, selectors: report };
2680
2575
  }
2576
+ // A cheap "can the agent still drive this page?" probe for /doctor and
2577
+ // startup. See ui-health.ts: it probes the ALWAYS-present controls (input,
2578
+ // send, toggles, new-chat) and reports the missing ones, so a DeepSeek
2579
+ // redesign surfaces as a clear message BEFORE a task hangs on it. Never
2580
+ // throws — a dead page reports every capability missing.
2581
+ async probeUiHealth() {
2582
+ if (!this.page) {
2583
+ return { ok: false, present: [], missing: [], missingCritical: [] };
2584
+ }
2585
+ const page = {
2586
+ count: (sel) => this.page.locator(sel).count(),
2587
+ countByText: (sel, textRe) => this.page.locator(sel).filter({ hasText: textRe }).count(),
2588
+ };
2589
+ return await probeUiHealth(page);
2590
+ }
2681
2591
  async _ensureSidebarOpen() {
2682
- const toggles = [
2683
- 'button[aria-label*="sidebar" i]',
2684
- 'button[aria-label*="история" i]',
2685
- 'button[aria-label*="history" i]',
2686
- 'button[class*="sidebar-toggle"]',
2687
- 'button[class*="sidebarToggle"]',
2688
- ];
2689
- for (const sel of toggles) {
2592
+ for (const sel of SIDEBAR_TOGGLE_SELECTORS) {
2690
2593
  try {
2691
2594
  const btn = this.page.locator(sel).first();
2692
2595
  if ((await btn.count()) === 0)
@@ -2750,7 +2653,7 @@ export class DeepSeekBrowser {
2750
2653
  catch { }
2751
2654
  }
2752
2655
  try {
2753
- await this.page.goto(`https://chat.deepseek.com/a/chat/s/${id}`, {
2656
+ await this.page.goto(chatUrl(id), {
2754
2657
  waitUntil: 'domcontentloaded',
2755
2658
  timeout: 20_000,
2756
2659
  });
@@ -2796,7 +2699,8 @@ export class DeepSeekBrowser {
2796
2699
  const res = await this.page
2797
2700
  .evaluate(async (opts) => {
2798
2701
  try {
2799
- const url = 'https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
2702
+ const url = opts.historyUrl +
2703
+ '?chat_session_id=' +
2800
2704
  encodeURIComponent(opts.chatId);
2801
2705
  const headers = {
2802
2706
  accept: 'application/json',
@@ -2845,7 +2749,7 @@ export class DeepSeekBrowser {
2845
2749
  catch (e) {
2846
2750
  return { error: 'fetch failed: ' + e.message, list: [] };
2847
2751
  }
2848
- }, { chatId: id, auth, pow })
2752
+ }, { chatId: id, auth, pow, historyUrl: HISTORY_MESSAGES_URL })
2849
2753
  .catch((e) => ({
2850
2754
  error: 'evaluate failed: ' + e.message,
2851
2755
  list: [],
@@ -2863,8 +2767,7 @@ export class DeepSeekBrowser {
2863
2767
  // when the in-page fetch was blocked (CSP, CORS, a page error).
2864
2768
  this._lastHistoryError = res.error;
2865
2769
  try {
2866
- const resp = await this.page.request.get('https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
2867
- encodeURIComponent(id), { headers: { accept: 'application/json' } });
2770
+ const resp = await this.page.request.get(HISTORY_MESSAGES_URL + '?chat_session_id=' + encodeURIComponent(id), { headers: { accept: 'application/json' } });
2868
2771
  if (!resp.ok())
2869
2772
  return list;
2870
2773
  const json = (await resp.json());
@@ -2911,12 +2814,14 @@ export class DeepSeekBrowser {
2911
2814
  if (!this.page)
2912
2815
  return [];
2913
2816
  const raw = await this.page
2914
- .evaluate(() => {
2817
+ .evaluate((opts) => {
2818
+ const think = new RegExp(opts.thinkRe, 'i');
2819
+ const assistantRe = new RegExp(opts.assistantRe, 'i');
2915
2820
  const inThink = (e) => {
2916
2821
  let n = e;
2917
2822
  while (n) {
2918
2823
  const cls = (n.className || '').toString();
2919
- if (/ds-think-content|thinking-content/i.test(cls))
2824
+ if (think.test(cls))
2920
2825
  return true;
2921
2826
  n = n.parentElement;
2922
2827
  }
@@ -2934,25 +2839,19 @@ export class DeepSeekBrowser {
2934
2839
  // detection is content-based, not class-prefix-based.
2935
2840
  const looksAssistant = (e) => {
2936
2841
  const cls = (e.className || '').toString();
2937
- if (/ds-assistant-message|assistant-message/i.test(cls))
2842
+ if (assistantRe.test(cls))
2938
2843
  return true;
2939
- if (e.querySelector('[class*="ds-assistant-message"]'))
2844
+ if (e.querySelector(opts.assistantSel))
2940
2845
  return true;
2941
2846
  // A user bubble has no rendered markdown; an answer does.
2942
- if (e.querySelector('[class*="ds-markdown"]'))
2847
+ if (e.querySelector(opts.markdownSel))
2943
2848
  return true;
2944
2849
  return false;
2945
2850
  };
2946
2851
  // Message-level containers first (broad), then the answer wrappers.
2947
- const containerSels = [
2948
- '[data-message-id]',
2949
- '[class*="ds-message"]',
2950
- '[class*="chat-message"]',
2951
- '[class*="message-item"]',
2952
- '[class*="_message"]',
2953
- ];
2852
+ // See MESSAGE_CONTAINER_SELECTORS in deepseek-ui.ts.
2954
2853
  let blocks = [];
2955
- for (const s of containerSels) {
2854
+ for (const s of opts.containerSels) {
2956
2855
  const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
2957
2856
  if (found.length) {
2958
2857
  blocks = found;
@@ -2983,12 +2882,8 @@ export class DeepSeekBrowser {
2983
2882
  }
2984
2883
  // Last resort: assistant answers only (no user turns) — better than
2985
2884
  // nothing when no message container matched.
2986
- const sels = [
2987
- 'div.ds-assistant-message-main-content',
2988
- 'div[class*="ds-assistant-message-main-content"]',
2989
- 'div[class*="ds-markdown"]',
2990
- ];
2991
- for (const s of sels) {
2885
+ // See RESTORE_ANSWER_SELECTORS in deepseek-ui.ts.
2886
+ for (const s of opts.answerSels) {
2992
2887
  const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
2993
2888
  if (!list.length)
2994
2889
  continue;
@@ -3000,6 +2895,13 @@ export class DeepSeekBrowser {
3000
2895
  break;
3001
2896
  }
3002
2897
  return out;
2898
+ }, {
2899
+ containerSels: MESSAGE_CONTAINER_SELECTORS,
2900
+ answerSels: RESTORE_ANSWER_SELECTORS,
2901
+ thinkRe: THINK_CLASS_RE.source,
2902
+ assistantRe: ASSISTANT_CLASS_RE.source,
2903
+ assistantSel: ASSISTANT_SELECTOR,
2904
+ markdownSel: ANSWER_MARKDOWN_SELECTOR,
3003
2905
  })
3004
2906
  .catch(() => []);
3005
2907
  return raw;
@@ -3007,7 +2909,7 @@ export class DeepSeekBrowser {
3007
2909
  async getCurrentChatId() {
3008
2910
  try {
3009
2911
  const url = this.page.url();
3010
- const m = url.match(/\/chat\/s\/([a-zA-Z0-9_-]+)/);
2912
+ const m = url.match(CHAT_ID_RE);
3011
2913
  if (m)
3012
2914
  return m[1];
3013
2915
  return this._netChatId;
package/dist/commands.js CHANGED
@@ -309,6 +309,20 @@ export function renderDoctor(d, t = translate('en')) {
309
309
  if (d.headless) {
310
310
  row(!!d.headlessUaCached, 'headless', d.headlessUaCached ? t('doctor.ua_cached') : t('doctor.ua_not_cached'));
311
311
  }
312
+ // The DeepSeek UI probe (see ui-health.ts). Rendered only when the caller
313
+ // ran it (the browser must be up); a missing capability is a WARN row, and a
314
+ // missing CRITICAL one is called out in words so the operator knows the next
315
+ // task will fail before it starts.
316
+ if (d.uiPresent || d.uiMissing) {
317
+ const missing = d.uiMissing || [];
318
+ const missingCritical = d.uiMissingCritical || [];
319
+ const summary = missing.length === 0
320
+ ? t('doctor.ui_ok')
321
+ : missingCritical.length > 0
322
+ ? t('doctor.ui_broken', { v: missingCritical.join(', ') })
323
+ : t('doctor.ui_degraded', { v: missing.join(', ') });
324
+ row(missing.length === 0, 'deepseek ui', summary);
325
+ }
312
326
  return t('doctor.title') + NL + rows.join(NL);
313
327
  }
314
328
  // ---------- /add-dir ----------
@@ -0,0 +1,225 @@
1
+ // DeepSeek UI constants — the SINGLE place where chat.deepseek.com specifics live.
2
+ //
3
+ // WHY this module exists: the agent drives the DeepSeek web UI through
4
+ // Playwright, so every selector, button label and protocol marker is a
5
+ // hardcoded assumption about a page we do not control. Keeping them scattered
6
+ // across browser.ts made a DeepSeek redesign a diffuse, easy-to-miss breakage
7
+ // (a renamed class here, a relabeled button there). Concentrating them here
8
+ // turns "DeepSeek changed the UI" into ONE file to audit, and lets the health
9
+ // check (probeUiHealth) report the exact broken capability to the operator
10
+ // BEFORE a task hangs on it.
11
+ //
12
+ // Priority order everywhere: prefer SEMANTIC anchors (role / aria-label /
13
+ // accessible name / input type) over DeepSeek's `ds-*` class names, which
14
+ // drift between builds. The `ds-*` selectors stay in the chains as a
15
+ // high-precision FIRST hit, with semantic fallbacks after them.
16
+ // ---------- endpoints ----------
17
+ export const CHAT_URL = 'https://chat.deepseek.com/';
18
+ export const CHAT_ID_RE = /\/chat\/s\/([a-zA-Z0-9_-]+)/;
19
+ // DeepSeek's chat URL shape. A direct navigation always yields the requested
20
+ // chat (the sidebar link click is tried first, this is the fallback).
21
+ export const CHAT_PATH_PREFIX = 'a/chat/s/';
22
+ export function chatUrl(id) {
23
+ return CHAT_URL + CHAT_PATH_PREFIX + id;
24
+ }
25
+ // The history endpoint returns EVERY message of a chat as JSON (the rendered
26
+ // page virtualizes long chats). Requested from INSIDE the page so the auth
27
+ // cookie and PoW handling are the browser's; see browser.fetchChatMessages.
28
+ export const HISTORY_MESSAGES_URL = 'https://chat.deepseek.com/api/v0/chat/history_messages';
29
+ // ---------- input ----------
30
+ export const INPUT_SELECTORS = [
31
+ 'textarea',
32
+ 'div[contenteditable="true"]',
33
+ '[role="textbox"]',
34
+ ];
35
+ // ---------- send ----------
36
+ // IMPORTANT: you MUST NOT add the generic
37
+ // 'div[role="button"][class*="ds-button--primary"]' here — it matches the
38
+ // STOP button too, and then _isGenerating() always returns true, so an answer
39
+ // is never considered ready.
40
+ export const SEND_SELECTORS = [
41
+ 'div[role="button"].ds-button--primary.ds-button--circle',
42
+ 'div[role="button"].ds-button--primary.ds-button--filled',
43
+ 'button[type="submit"]',
44
+ 'button[aria-label*="send" i]',
45
+ 'button[aria-label*="отправ" i]',
46
+ ];
47
+ // DeepSeek's own button classes. Used as a coarse "is this a DeepSeek button?"
48
+ // filter in the raw-DOM fallbacks for Stop and for the send/stop icon
49
+ // detection. Kept as a source string because the fallback runs INSIDE
50
+ // page.evaluate, which cannot receive a RegExp object.
51
+ export const BUTTON_CLASS_RE = /ds-button--(circle|primary|filled)/i;
52
+ // ---------- stop ----------
53
+ export const STOP_SELECTORS = [
54
+ 'div[role="button"][aria-label*="stop" i]',
55
+ 'div[role="button"][aria-label*="останов" i]',
56
+ 'button:has-text("Stop")',
57
+ 'button:has-text("Остановить")',
58
+ 'button[aria-label*="Stop" i]',
59
+ ];
60
+ // Accessible name of the Stop button, for a TRUSTED Playwright click. A bare
61
+ // stop word (optionally with a short suffix), anchored and length-limited so a
62
+ // "Stop" inside rendered prose is never clicked.
63
+ export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
64
+ // ---------- continue ----------
65
+ // DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle on
66
+ // the server caps the THINK phase: the reasoning stops mid-way and the UI
67
+ // offers a Continue button (NOT the same as a truncated turn with
68
+ // generation_err — there the answer FAILED; here the model paused). The agent
69
+ // must click it so the reasoning/answer keeps flowing.
70
+ //
71
+ // Reasoning mode uses a LONGER label («Продолжить размышление» / «Continue
72
+ // thinking»), not a bare «Continue», so the match is a regex: a bare continue
73
+ // word OR the word plus a short reasoning/answer suffix. It is anchored and
74
+ // length-limited, so a "Continue" inside rendered prose is never clicked.
75
+ export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
76
+ // ---------- messages / answers ----------
77
+ // The rendered assistant answer. The FIRST hit is preferred (most specific),
78
+ // so `ds-markdown` stays ahead of the generic `markdown` catch-all.
79
+ export const ANSWER_SELECTORS = [
80
+ 'div.ds-assistant-message-main-content',
81
+ 'div[class*="ds-assistant-message-main-content"]',
82
+ 'div[class*="ds-markdown"]',
83
+ 'div[class*="markdown"]',
84
+ ];
85
+ // Reasoning blocks. They are NOT the answer and must never be picked up as
86
+ // one, otherwise the terminal would show the model's long reasoning.
87
+ export const THINK_CLASS_RE = /ds-think-content|thinking-content/i;
88
+ // Last-resort answer-only selectors for the restored-dialogue scraper. The
89
+ // generic `markdown` catch-all is deliberately absent here: without a message
90
+ // container it would also match a user bubble that happens to render markdown.
91
+ export const RESTORE_ANSWER_SELECTORS = [
92
+ 'div.ds-assistant-message-main-content',
93
+ 'div[class*="ds-assistant-message-main-content"]',
94
+ 'div[class*="ds-markdown"]',
95
+ ];
96
+ // Message-level containers for the restored dialogue (broad first).
97
+ export const MESSAGE_CONTAINER_SELECTORS = [
98
+ '[data-message-id]',
99
+ '[class*="ds-message"]',
100
+ '[class*="chat-message"]',
101
+ '[class*="message-item"]',
102
+ '[class*="_message"]',
103
+ ];
104
+ // A message block is "assistant" when it carries the assistant class OR a
105
+ // rendered markdown wrapper (a user bubble has no rendered markdown). Used by
106
+ // the restored-dialogue scraper; the class names drift between builds, so the
107
+ // detection is content-based rather than class-prefix-only.
108
+ export const ASSISTANT_CLASS_RE = /ds-assistant-message|assistant-message/i;
109
+ export const ASSISTANT_SELECTOR = '[class*="ds-assistant-message"]';
110
+ export const ANSWER_MARKDOWN_SELECTOR = '[class*="ds-markdown"]';
111
+ // A cheap "did the page grow?" signal (_chatSignal): count message nodes and
112
+ // measure the LAST answer node's text length. The old code read
113
+ // document.body.innerText.length every tick, which serializes the WHOLE page
114
+ // (sidebar, history, menus) and forces a layout — expensive on a long chat.
115
+ export const CHAT_SIGNAL_NODES_SELECTOR = '[class*="ds-message"], [class*="chat-message"], .ds-markdown';
116
+ export const MARKDOWN_SELECTOR = '.ds-markdown';
117
+ // ---------- new chat ----------
118
+ // The "New chat" control is NOT a <button>/<a> on the current DeepSeek build
119
+ // (it is a <div>), so matching only button/a missed it. Match any
120
+ // clickable-ish element whose text is EXACTLY the new-chat label (an anchored
121
+ // regex keeps a big outer container from matching its long descendant text).
122
+ export const NEW_CHAT_SELECTOR = 'button, a, [role="button"], div, span';
123
+ export const NEW_CHAT_TEXT_RE = /^\s*(new chat|новый чат|новый диалог)\s*$/i;
124
+ // ---------- login ----------
125
+ // The login page is served on the same origin and swaps in a password field;
126
+ // the exact classes change, so we match loosely on input types and the known
127
+ // placeholder/autocomplete attributes.
128
+ export const PASSWORD_SELECTORS = [
129
+ 'input[type="password"]',
130
+ 'input[autocomplete="current-password"]',
131
+ 'input[autocomplete="new-password"]',
132
+ ];
133
+ export const LOGIN_SELECTORS = [
134
+ 'input[type="email"]',
135
+ 'input[type="tel"]',
136
+ 'input[name="email"]',
137
+ 'input[name="phone"]',
138
+ 'input[name="username"]',
139
+ 'input[placeholder*="email" i]',
140
+ 'input[placeholder*="phone" i]',
141
+ 'input[placeholder*="телефон" i]',
142
+ 'input[placeholder*="почт" i]',
143
+ 'input[autocomplete="username"]',
144
+ 'input[autocomplete="email"]',
145
+ ];
146
+ // Any login-ish button: a primary DeepSeek button or a plain <button>. The
147
+ // visible label is filtered by LOGIN_NAME_RE so "Log in with Google" is not
148
+ // hit by mistake.
149
+ export const LOGIN_BUTTON_SELECTOR = 'div[role="button"].ds-button--primary, button';
150
+ export const LOGIN_NAME_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
151
+ // A looser label test used inside the form fallback: a Continue button can also
152
+ // submit the login step on some builds.
153
+ export const LOGIN_FORM_SUBMIT_RE = /log ?in|sign ?in|войти|continue|продолж/i;
154
+ export const LOGIN_SUBMIT_SELECTORS = [
155
+ // DeepSeek's real button: a div[role=button] with these classes and a
156
+ // <span class="ds-button__content">Log in</span> inside.
157
+ 'div[role="button"].ds-button--primary.ds-button--filled',
158
+ 'div[role="button"].ds-button--primary',
159
+ 'button[type="submit"]',
160
+ 'button:has-text("Log in")',
161
+ 'button:has-text("Sign in")',
162
+ 'button:has-text("Войти")',
163
+ 'div[role="button"]:has-text("Log in")',
164
+ 'div[role="button"]:has-text("Sign in")',
165
+ 'div[role="button"]:has-text("Войти")',
166
+ ];
167
+ // ---------- sidebar / chats ----------
168
+ export const SIDEBAR_TOGGLE_SELECTORS = [
169
+ 'button[aria-label*="sidebar" i]',
170
+ 'button[aria-label*="история" i]',
171
+ 'button[aria-label*="history" i]',
172
+ 'button[class*="sidebar-toggle"]',
173
+ 'button[class*="sidebarToggle"]',
174
+ ];
175
+ // ---------- toggles (deep thinking / web search) ----------
176
+ // DeepSeek exposes two toggle buttons above the input. Their labels are
177
+ // localized, so we match by a loose regex on the visible text and read the
178
+ // state from aria-pressed. `.ds-toggle-button` is the class anchor; the
179
+ // semantic fallback ([aria-pressed] or [role=switch]) is tried when the class
180
+ // is renamed, so a redesign does not silently disable the toggles.
181
+ export const TOGGLE_SELECTORS = [
182
+ '.ds-toggle-button',
183
+ '[role="switch"]',
184
+ 'div[role="button"][aria-pressed]',
185
+ 'button[aria-pressed]',
186
+ ];
187
+ export const DEEP_THINKING_RE = /глубок|deep\s*think/i;
188
+ export const WEB_SEARCH_RE = /поиск|search/i;
189
+ // ---------- attachments ----------
190
+ export const ATTACH_SELECTORS = [
191
+ 'div[role="button"][aria-label*="attach" i]',
192
+ 'div[role="button"][aria-label*="влож" i]',
193
+ 'button[aria-label*="attach" i]',
194
+ 'button[aria-label*="влож" i]',
195
+ '[class*="upload"]',
196
+ '[class*="attach"]',
197
+ ];
198
+ // ---------- service statuses / errors ----------
199
+ // DeepSeek UI service statuses that are NOT the model's answer. Otherwise the
200
+ // agent takes a status (Reading...) for an answer and breaks parsing.
201
+ export const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
202
+ // DeepSeek's answer when the rate limit is exceeded.
203
+ //
204
+ // IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
205
+ // part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
206
+ // Try again later.", "Service unavailable. Try again later."), so matching it
207
+ // here classified every server hiccup as a RATE LIMIT and sent the agent into
208
+ // a 5-minute wait. The rate limit is recognized by its specific wording only.
209
+ export const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
210
+ // Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
211
+ // rate limit, these usually clear in seconds, so we retry quickly instead of
212
+ // waiting minutes.
213
+ export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
214
+ // ---------- network protocol (SSE) ----------
215
+ export const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
216
+ export const INCOMPLETE_STATUS_RE = /"quasi_status","v":"INCOMPLETE"/i;
217
+ // A turn that ENDED (`quasi_status: FINISHED`) but produced NO answer text —
218
+ // the reasoning was generated and the model stopped WITHOUT a RESPONSE
219
+ // fragment. The UI shows Stopped + Continue.
220
+ export const FINISHED_STATUS_RE = /"quasi_status","v":"FINISHED"/i;
221
+ export const RESPONSE_CONTENT_RE = /"type":"RESPONSE","content":"[^"]/;
222
+ // Only a completion/continue turn carries a real answer. Gating the capture
223
+ // assignment to these URLs keeps a future endpoint (which happens to return a
224
+ // `content`/`text` field) from clobbering the answer and looking fresh.
225
+ export const ANSWER_URL_RE = /chat\/(completion|continue)/i;
@@ -38,6 +38,26 @@ export const doctorMessages = {
38
38
  ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
39
39
  en: 'no cached UA yet (first start relaunches once)',
40
40
  },
41
+ 'doctor.ui_ok': {
42
+ ru: 'все ключевые элементы найдены',
43
+ en: 'all key elements found',
44
+ },
45
+ 'doctor.ui_degraded': {
46
+ ru: 'часть элементов не найдена: {v} (агент может работать некорректно)',
47
+ en: 'some elements not found: {v} (the agent may misbehave)',
48
+ },
49
+ 'doctor.ui_broken': {
50
+ ru: 'НЕ найдено критичное: {v} — DeepSeek сменил вёрстку, задачи не будут отправляться. Запусти /debug-dom.',
51
+ en: 'MISSING critical: {v} — DeepSeek changed the markup, tasks will not send. Run /debug-dom.',
52
+ },
53
+ 'ui.probe_failed': {
54
+ ru: '⚠ DeepSeek UI: не найдено критичное ({v}) — возможно, сменилась вёрстка chat.deepseek.com. Задачи могут не отправляться; проверь /doctor и /debug-dom.',
55
+ en: '⚠ DeepSeek UI: missing critical element(s) ({v}) — chat.deepseek.com markup may have changed. Tasks may not send; check /doctor and /debug-dom.',
56
+ },
57
+ 'ui.probe_degraded': {
58
+ ru: '⚠ DeepSeek UI: не найдено ({v}) — часть возможностей (тумблеры/новый чат) может не работать.',
59
+ en: '⚠ DeepSeek UI: not found ({v}) — some features (toggles/new chat) may not work.',
60
+ },
41
61
  'adddir.usage': {
42
62
  ru: 'Использование: /add-dir <path>',
43
63
  en: 'Usage: /add-dir <path>',
package/dist/index.js CHANGED
@@ -997,6 +997,23 @@ async function main() {
997
997
  await transcript.close();
998
998
  process.exit(1);
999
999
  }
1000
+ // DeepSeek UI health probe. The agent drives a page we do not control, so a
1001
+ // redesign silently breaks selectors and the FIRST symptom is a task hanging
1002
+ // on "input field not found". Probe the always-present controls right after
1003
+ // login and warn the operator up front. Best-effort: a probe failure must
1004
+ // never block startup.
1005
+ const warnUiHealth = (h) => {
1006
+ if (h.missingCritical.length) {
1007
+ console.error(theme.warn(t('ui.probe_failed', { v: h.missingCritical.join(', ') })));
1008
+ }
1009
+ else if (h.missing.length) {
1010
+ console.error(theme.warn(t('ui.probe_degraded', { v: h.missing.join(', ') })));
1011
+ }
1012
+ };
1013
+ try {
1014
+ warnUiHealth(await browser.probeUiHealth());
1015
+ }
1016
+ catch { }
1000
1017
  // One-shot mode
1001
1018
  if (task) {
1002
1019
  const baseTools = mod.createTools(currentWorkdir, {
@@ -2940,6 +2957,9 @@ async function main() {
2940
2957
  const mcpStatus = mcpPool
2941
2958
  ? mcpPool.status()
2942
2959
  : { servers: [], toolCount: 0 };
2960
+ // Probe the DeepSeek UI so a redesign shows up as a /doctor row instead
2961
+ // of a task that hangs on a missing input. Best-effort.
2962
+ const uiHealth = await browser.probeUiHealth().catch(() => null);
2943
2963
  console.log(theme.system(mod.commands.renderDoctor({
2944
2964
  nodeVersion: process.version,
2945
2965
  platform: process.platform,
@@ -2961,6 +2981,9 @@ async function main() {
2961
2981
  sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
2962
2982
  headless: !!headless,
2963
2983
  headlessUaCached: await headlessUACacheReady().catch(() => false),
2984
+ uiPresent: uiHealth?.present,
2985
+ uiMissing: uiHealth?.missing,
2986
+ uiMissingCritical: uiHealth?.missingCritical,
2964
2987
  }, t)));
2965
2988
  continue;
2966
2989
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { FINISHED_STATUS_RE, RESPONSE_CONTENT_RE } from './deepseek-ui.js';
4
5
  // Extraction of the RAW model answer text from DeepSeek's network data.
5
6
  //
6
7
  // Why: the answer is read from the RENDERED DOM, and DeepSeek's rendering
@@ -175,11 +176,11 @@ export function isFinishedWithoutAnswer(body) {
175
176
  const b = String(body || '');
176
177
  if (!b)
177
178
  return false;
178
- const finished = /"quasi_status","v":"FINISHED"/i.test(b);
179
+ const finished = FINISHED_STATUS_RE.test(b);
179
180
  if (!finished)
180
181
  return false;
181
182
  // A RESPONSE fragment with actual content means the model DID answer.
182
- if (/"type":"RESPONSE","content":"[^"]/.test(b))
183
+ if (RESPONSE_CONTENT_RE.test(b))
183
184
  return false;
184
185
  // The turn carried reasoning (THINK) or was truncated mid-way; treat the
185
186
  // absence of response content as "no answer produced".
@@ -0,0 +1,71 @@
1
+ // UI health check — a cheap "can the agent still drive this page?" probe.
2
+ //
3
+ // WHY: every selector in deepseek-ui.ts is an assumption about a page we do
4
+ // not control. When DeepSeek ships a redesign, the FIRST symptom used to be a
5
+ // task hanging on "input field not found" (or a silently mis-clicked send),
6
+ // minutes into a run. This module turns that into an explicit answer BEFORE
7
+ // the task: it checks the capabilities the agent ALWAYS needs and reports the
8
+ // missing ones. The page object is injected (the browser owns Playwright), so
9
+ // the decision logic here stays pure and unit-testable.
10
+ //
11
+ // Only ALWAYS-PRESENT capabilities are probed. The Stop button and the answer
12
+ // nodes are TRANSIENT (Stop exists only during generation, answers only after
13
+ // a turn), so a presence probe on a healthy idle page would report false
14
+ // negatives; they are deliberately excluded.
15
+ import { INPUT_SELECTORS, SEND_SELECTORS, TOGGLE_SELECTORS, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, } from './deepseek-ui.js';
16
+ // Critical: without the input the prompt cannot be typed, without send it
17
+ // cannot be submitted — a task would hang on `ds.input_missing`. Advisory:
18
+ // toggles/newChat degrade gracefully (the toggles fall back to the configured
19
+ // state, newChat falls back to navigating to the base URL).
20
+ export const UI_CAPABILITIES = [
21
+ { capability: 'input', selectors: INPUT_SELECTORS, critical: true },
22
+ { capability: 'send', selectors: SEND_SELECTORS, critical: true },
23
+ { capability: 'toggles', selectors: TOGGLE_SELECTORS, critical: false },
24
+ { capability: 'newChat', selectors: [NEW_CHAT_SELECTOR], critical: false },
25
+ ];
26
+ async function hasAny(page, sel, textRe) {
27
+ try {
28
+ const n = textRe
29
+ ? await page.countByText(sel, textRe)
30
+ : await page.count(sel);
31
+ return n > 0;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /**
38
+ * Probe the always-present DeepSeek controls. Never throws: a dead page is
39
+ * reported as every capability missing, which the caller surfaces to the
40
+ * operator instead of letting the next task hang.
41
+ */
42
+ export async function probeUiHealth(page) {
43
+ const present = [];
44
+ const missing = [];
45
+ const missingCritical = [];
46
+ for (const spec of UI_CAPABILITIES) {
47
+ let found = false;
48
+ // The new-chat control is matched by an EXACT text label on a broad
49
+ // element set (it is a <div> on the current build, not a <button>).
50
+ const textRe = spec.capability === 'newChat' ? NEW_CHAT_TEXT_RE : undefined;
51
+ for (const sel of spec.selectors) {
52
+ if (await hasAny(page, sel, textRe)) {
53
+ found = true;
54
+ break;
55
+ }
56
+ }
57
+ if (found)
58
+ present.push(spec.capability);
59
+ else {
60
+ missing.push(spec.capability);
61
+ if (spec.critical)
62
+ missingCritical.push(spec.capability);
63
+ }
64
+ }
65
+ return {
66
+ ok: missingCritical.length === 0,
67
+ present,
68
+ missing,
69
+ missingCritical,
70
+ };
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.69.0",
3
+ "version": "2.70.0",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {