zames_pro 2.69.0 → 2.71.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/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 ----------
package/dist/config.js CHANGED
@@ -50,6 +50,10 @@ export const DEFAULTS = {
50
50
  autoCompact: false,
51
51
  autoCompactPct: 95,
52
52
  resendPromptOnResume: false,
53
+ // Subagents on by default: the Task tool is what keeps heavy exploration
54
+ // out of the main context, and the main chat is restored afterwards.
55
+ subagents: true,
56
+ maxSubagents: 3,
53
57
  deepThinking: false,
54
58
  webSearch: true,
55
59
  auth: {
@@ -318,6 +322,20 @@ export const CONFIG_SCHEMA = [
318
322
  labelKey: 'cfg.f.browser_resendPromptOnResume',
319
323
  groupKey: 'cfg.group.browser',
320
324
  },
325
+ {
326
+ path: 'browser.subagents',
327
+ type: 'boolean',
328
+ labelKey: 'cfg.f.browser_subagents',
329
+ groupKey: 'cfg.group.browser',
330
+ },
331
+ {
332
+ path: 'browser.maxSubagents',
333
+ type: 'number',
334
+ min: 0,
335
+ max: 20,
336
+ labelKey: 'cfg.f.browser_maxSubagents',
337
+ groupKey: 'cfg.group.browser',
338
+ },
321
339
  {
322
340
  path: 'browser.deepThinking',
323
341
  type: 'boolean',