zames_pro 2.19.0 → 2.21.1

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/README.md CHANGED
@@ -158,8 +158,9 @@ Codex CLI:
158
158
 
159
159
  The token context is also shown live: the status line above the input has the
160
160
  spinner/text on the left and the context on the right (e.g. `125k · 13%`,
161
- percent of a 1M context). It comes from DeepSeek's `accumulated_token_usage`
162
- and is hidden until the first answer delivers it.
161
+ percent of a 1M context). It is COLORED by fill level: green below 50%,
162
+ yellow 50-80%, red above 80%. It comes from DeepSeek's
163
+ `accumulated_token_usage` and is hidden until the first answer delivers it.
163
164
 
164
165
 
165
166
  ## Project context, skills and memory
@@ -47,7 +47,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
47
47
  id = await browser.getCurrentChatId();
48
48
  }
49
49
  catch {
50
- id = null;
50
+ // keep id = null
51
51
  }
52
52
  if (id && id !== lastReportedChatId) {
53
53
  lastReportedChatId = id;
@@ -56,7 +56,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
56
56
  };
57
57
  await reportChat();
58
58
  if (sendSystemPrompt) {
59
- let gitText = null;
59
+ let gitText;
60
60
  try {
61
61
  const ctx = await getGitContext(workdir);
62
62
  gitText = formatGitContext(ctx);
@@ -64,7 +64,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
64
64
  catch (e) {
65
65
  gitText = `(git context error: ${e.message})`;
66
66
  }
67
- let context = null;
67
+ let context;
68
68
  try {
69
69
  context = await loadProjectContext(workdir);
70
70
  }
@@ -267,7 +267,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
267
267
  // A stale answer is discarded only when it does NOT parse as a tool
268
268
  // call (see wdStale above); an echo of a call that never ran is a real
269
269
  // call and must be executed, not dropped.
270
- if (!isFirst && (wdEmpty || wdStale || wdNoCall) && watchdogRetries < MAX_WATCHDOG_RETRIES) {
270
+ if (!isFirst &&
271
+ (wdEmpty || wdStale || wdNoCall) &&
272
+ watchdogRetries < MAX_WATCHDOG_RETRIES) {
271
273
  watchdogRetries++;
272
274
  transcript?.log('watchdog_nudge', {
273
275
  attempt: watchdogRetries,
@@ -372,7 +374,8 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
372
374
  // README…" or "Let me run the tests…" and goes silent. If this is taken
373
375
  // as final, the agent stalls without doing the work. We ask it to
374
376
  // continue and to actually call a tool this time (or respond if truly done).
375
- if (looksLikeUnfinishedWork(trimmed) && looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
377
+ if (looksLikeUnfinishedWork(trimmed) &&
378
+ looksDoneRetries < MAX_LOOKSDONE_RETRIES) {
376
379
  looksDoneRetries++;
377
380
  unparsedRetries++;
378
381
  transcript?.log('unfinished_retry', {
@@ -454,9 +457,12 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
454
457
  // a READABLE report, but say explicitly that the task may be incomplete:
455
458
  // never let a reasoning paragraph masquerade as a finished result.
456
459
  const lastText = (rawResponse || '').trim();
457
- return (lastText
458
- ? lastText + String.fromCharCode(10) + String.fromCharCode(10) + '(The model stopped calling tools before finishing. The task may be incomplete - check the DeepSeek chat.)'
459
- : 'The model stopped calling tools before finishing the task.');
460
+ return lastText
461
+ ? lastText +
462
+ String.fromCharCode(10) +
463
+ String.fromCharCode(10) +
464
+ '(The model stopped calling tools before finishing. The task may be incomplete - check the DeepSeek chat.)'
465
+ : 'The model stopped calling tools before finishing the task.';
460
466
  }
461
467
  const suspiciousFinal = responseLooksLikeToolCall(rawResponse) ||
462
468
  looksLikeUnfinishedWork((rawResponse || '').trim()) ||
@@ -608,8 +614,8 @@ export function responseLooksLikeToolCall(rawResponse) {
608
614
  // argument key (args/command/path/...). We require the opening bracket at
609
615
  // the start (after spaces/prefix) so we don't catch ordinary prose with
610
616
  // colons like "path: ...".
611
- /^\s*[\[\{]/.test(raw) &&
612
- /["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw) ||
617
+ (/^\s*[\[\{]/.test(raw) &&
618
+ /["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw)) ||
613
619
  /<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
614
620
  /^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
615
621
  }
@@ -839,7 +845,7 @@ function parseArgsPermissive(str) {
839
845
  break;
840
846
  if (str[i] !== '"')
841
847
  return null;
842
- let keyEnd = str.indexOf('"', i + 1);
848
+ const keyEnd = str.indexOf('"', i + 1);
843
849
  if (keyEnd === -1)
844
850
  return null;
845
851
  const key = str.slice(i + 1, keyEnd);
@@ -994,7 +1000,9 @@ function parseToolCallPermissive(text) {
994
1000
  if (braceIdx === -1)
995
1001
  return null;
996
1002
  const endBrace = findMatching(text, braceIdx, '{', '}');
997
- const objText = endBrace === -1 ? text.slice(braceIdx) : text.slice(braceIdx, endBrace + 1);
1003
+ const objText = endBrace === -1
1004
+ ? text.slice(braceIdx)
1005
+ : text.slice(braceIdx, endBrace + 1);
998
1006
  const inlineArgs = parseArgsPermissive(objText) || parseArgsGreedy(objText);
999
1007
  if (!inlineArgs)
1000
1008
  return null;
@@ -4,14 +4,23 @@ import os from 'os';
4
4
  import { execFileSync } from 'child_process';
5
5
  import { extractPathToken } from './path-token.js';
6
6
  export { extractPathToken };
7
- export const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico'];
7
+ export const IMAGE_EXTS = [
8
+ '.png',
9
+ '.jpg',
10
+ '.jpeg',
11
+ '.gif',
12
+ '.webp',
13
+ '.bmp',
14
+ '.svg',
15
+ '.ico',
16
+ ];
8
17
  export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
9
18
  function sanitize(name) {
10
- return String(name || '')
19
+ return (String(name || '')
11
20
  .replace(/[\u0000-\u001f\u007f]/g, '')
12
21
  .replace(/[\\/:*?"<>|]/g, '_')
13
22
  .replace(/\s+/g, '_')
14
- .slice(0, 80) || 'file';
23
+ .slice(0, 80) || 'file');
15
24
  }
16
25
  export function guessMime(name) {
17
26
  const ext = path.extname(String(name || '')).toLowerCase();
@@ -74,7 +83,10 @@ export function parseDataUrl(text) {
74
83
  if (!m)
75
84
  return null;
76
85
  try {
77
- return { mime: m[1] || 'application/octet-stream', data: Buffer.from(m[2].replace(/\s+/g, ''), 'base64') };
86
+ return {
87
+ mime: m[1] || 'application/octet-stream',
88
+ data: Buffer.from(m[2].replace(/\s+/g, ''), 'base64'),
89
+ };
78
90
  }
79
91
  catch {
80
92
  return null;
@@ -189,7 +201,9 @@ export function hasClipboardTool() {
189
201
  if (process.platform === 'win32' || process.platform === 'darwin')
190
202
  return true;
191
203
  if (process.platform === 'linux') {
192
- return Boolean(tryCommand('xclip', ['-version']) || tryCommand('xsel', ['--version']) || tryCommand('wl-paste', ['--version']));
204
+ return Boolean(tryCommand('xclip', ['-version']) ||
205
+ tryCommand('xsel', ['--version']) ||
206
+ tryCommand('wl-paste', ['--version']));
193
207
  }
194
208
  return false;
195
209
  }
@@ -238,7 +252,9 @@ const PS_GET_FILE_DROP = 'Add-Type -AssemblyName System.Windows.Forms; ' +
238
252
  'if($d -and $d.GetDataPresent([System.Windows.Forms.DataFormats]::FileDrop)){' +
239
253
  '$f=$d.GetData([System.Windows.Forms.DataFormats]::FileDrop); $f -join [Environment]::NewLine}';
240
254
  export async function readClipboardImageDetailed() {
241
- if (process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd') {
255
+ if (process.platform === 'linux' ||
256
+ process.platform === 'freebsd' ||
257
+ process.platform === 'openbsd') {
242
258
  const wayland = process.env.WAYLAND_DISPLAY;
243
259
  const x11 = process.env.DISPLAY;
244
260
  const attempts = [];
@@ -249,14 +265,34 @@ export async function readClipboardImageDetailed() {
249
265
  return { data: win, via: 'powershell.exe' };
250
266
  }
251
267
  if (wayland || !x11)
252
- attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
268
+ attempts.push({
269
+ bin: 'wl-paste',
270
+ args: ['--type', 'image/png', '--no-newline'],
271
+ via: 'wl-paste',
272
+ });
253
273
  if (x11 || !wayland) {
254
- attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
255
- attempts.push({ bin: 'xsel', args: ['--clipboard', '--output'], via: 'xsel' });
274
+ attempts.push({
275
+ bin: 'xclip',
276
+ args: ['-selection', 'clipboard', '-t', 'image/png', '-o'],
277
+ via: 'xclip',
278
+ });
279
+ attempts.push({
280
+ bin: 'xsel',
281
+ args: ['--clipboard', '--output'],
282
+ via: 'xsel',
283
+ });
256
284
  }
257
285
  // Even if the session env is missing, try the other family as a fallback.
258
- attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
259
- attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
286
+ attempts.push({
287
+ bin: 'wl-paste',
288
+ args: ['--type', 'image/png', '--no-newline'],
289
+ via: 'wl-paste',
290
+ });
291
+ attempts.push({
292
+ bin: 'xclip',
293
+ args: ['-selection', 'clipboard', '-t', 'image/png', '-o'],
294
+ via: 'xclip',
295
+ });
260
296
  const tried = [];
261
297
  for (const a of attempts) {
262
298
  if (tried.includes(a.via))
@@ -272,7 +308,10 @@ export async function readClipboardImageDetailed() {
272
308
  }
273
309
  if (process.platform === 'darwin') {
274
310
  const data = tryCommand('pngpaste', ['-']);
275
- return { data: data && data.length ? data : null, via: data ? 'pngpaste' : 'no-tool:pngpaste' };
311
+ return {
312
+ data: data && data.length ? data : null,
313
+ via: data ? 'pngpaste' : 'no-tool:pngpaste',
314
+ };
276
315
  }
277
316
  if (process.platform === 'win32') {
278
317
  // PowerShell reads the clipboard image and writes raw PNG bytes to stdout.
@@ -281,8 +320,16 @@ export async function readClipboardImageDetailed() {
281
320
  'if($img){$ms=New-Object System.IO.MemoryStream; ' +
282
321
  '$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
283
322
  '[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
284
- const data = tryCommand('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
285
- return { data: data && data.length ? data : null, via: data ? 'powershell' : 'no-tool:powershell' };
323
+ const data = tryCommand('powershell', [
324
+ '-NoProfile',
325
+ '-NonInteractive',
326
+ '-Command',
327
+ ps,
328
+ ]);
329
+ return {
330
+ data: data && data.length ? data : null,
331
+ via: data ? 'powershell' : 'no-tool:powershell',
332
+ };
286
333
  }
287
334
  return { data: null, via: 'unsupported-platform' };
288
335
  }
@@ -329,7 +376,13 @@ export async function findFileByName(workdir, name) {
329
376
  export function readWindowsClipboardFiles() {
330
377
  if (!isWsl())
331
378
  return [];
332
- const out = tryCommand('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-Command', PS_GET_FILE_DROP]);
379
+ const out = tryCommand('powershell.exe', [
380
+ '-STA',
381
+ '-NoProfile',
382
+ '-NonInteractive',
383
+ '-Command',
384
+ PS_GET_FILE_DROP,
385
+ ]);
333
386
  if (!out)
334
387
  return [];
335
388
  const text = out.toString('utf-8');
@@ -360,10 +413,20 @@ export function winPathToWsl(winPath) {
360
413
  export async function readWindowsClipboardImage() {
361
414
  if (!isWsl())
362
415
  return null;
363
- const out = tryCommand('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-EncodedCommand', PS_IMAGE_ENCODED]);
416
+ const out = tryCommand('powershell.exe', [
417
+ '-STA',
418
+ '-NoProfile',
419
+ '-NonInteractive',
420
+ '-EncodedCommand',
421
+ PS_IMAGE_ENCODED,
422
+ ]);
364
423
  if (!out)
365
424
  return null;
366
- const first = out.toString('utf-8').trim().split(String.fromCharCode(10))[0].trim();
425
+ const first = out
426
+ .toString('utf-8')
427
+ .trim()
428
+ .split(String.fromCharCode(10))[0]
429
+ .trim();
367
430
  if (!first)
368
431
  return null;
369
432
  const wslPath = winPathToWsl(first);
package/dist/browser.js CHANGED
@@ -165,7 +165,15 @@ const STOP_SELECTORS = [
165
165
  // Otherwise the agent takes a status (Reading...) for an answer and breaks parsing.
166
166
  const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
167
167
  // DeepSeek's answer when the rate limit is exceeded.
168
- const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|повторите позже|try again later)/i;
168
+ //
169
+ // IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
170
+ // part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
171
+ // Try again later.", "Service unavailable. Try again later."), so matching it
172
+ // here classified every server hiccup as a RATE LIMIT and sent the agent into
173
+ // a 5-minute wait (and, after the finish-loop stopped reading toasts every
174
+ // tick, looked like "the agent hung"). The rate limit is recognized by its
175
+ // specific wording only.
176
+ const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
169
177
  // Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
170
178
  // rate limit, these usually clear in seconds, so we retry quickly instead of
171
179
  // waiting minutes. "Server busy", 503, "temporarily unavailable", etc.
@@ -415,7 +423,7 @@ export class DeepSeekBrowser {
415
423
  try {
416
424
  this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
417
425
  }
418
- catch (e2) {
426
+ catch {
419
427
  if (this.debug)
420
428
  console.error('profile: сношу целиком');
421
429
  await nukeProfile();
@@ -501,7 +509,13 @@ export class DeepSeekBrowser {
501
509
  await this.context.close();
502
510
  }
503
511
  catch { }
504
- await this.launch();
512
+ // A restart must REUSE the existing profile. `launch()` would call
513
+ // killStaleChrome() and clean Singleton files, which can race with a
514
+ // sibling agent on the same profile; here we just relaunch the persistent
515
+ // context in place. A fresh window appears only because the old context was
516
+ // actually closed — no second window is created while one is alive.
517
+ await this._launchOnce();
518
+ await this._fixHeadlessUserAgent();
505
519
  }
506
520
  async waitForLogin() {
507
521
  // 1) Already signed in (session cookie in the persistent profile)?
@@ -1096,7 +1110,11 @@ export class DeepSeekBrowser {
1096
1110
  })));
1097
1111
  // Interruptible: Esc/Ctrl+C must cancel this long wait too,
1098
1112
  // otherwise "stop" stays frozen for up to 5 minutes.
1099
- const aborted = await this._sleepInterruptible(this.rateLimitWaitMs);
1113
+ // The wait is ANIMATED through onSendPause (a live countdown), so the
1114
+ // operator sees the agent is waiting on the rate limit instead of a
1115
+ // frozen spinner. `_lastSentAt` is bumped so the countdown renders.
1116
+ const waitSec = Math.ceil(this.rateLimitWaitMs / 1000);
1117
+ const aborted = await this._waitRateLimit(waitSec);
1100
1118
  if (aborted)
1101
1119
  return '(прервано пользователем)';
1102
1120
  continue;
@@ -1129,7 +1147,12 @@ export class DeepSeekBrowser {
1129
1147
  max: this.askRetries,
1130
1148
  error: e.message,
1131
1149
  })));
1132
- if (/closed|crash|Target page|browser/i.test(e.message)) {
1150
+ // Only a genuinely LOST page justifies a restart. "Target page,
1151
+ // context or browser has been closed" means the page is gone for good;
1152
+ // a bare "browser" mention (e.g. a selector error containing the word)
1153
+ // must NOT tear the window down — that was the "a new window opened"
1154
+ // surprise during a resumed chat. We re-open the SAME context instead.
1155
+ if (/target page, context or browser has been closed|page.*has been closed/i.test(e.message)) {
1133
1156
  console.error(theme.warn(this._t('ds.ask_restart_browser')));
1134
1157
  try {
1135
1158
  await this.restart();
@@ -1196,13 +1219,14 @@ export class DeepSeekBrowser {
1196
1219
  // replied to the wrong thing (or nothing), and the loop looked stalled.
1197
1220
  if (norm(got) !== norm(text)) {
1198
1221
  await input.click();
1199
- await this.page.keyboard.press("Control+A");
1200
- await this.page.keyboard.press("Delete");
1222
+ await this.page.keyboard.press('Control+A');
1223
+ await this.page.keyboard.press('Delete');
1201
1224
  await this.page.keyboard.insertText(text);
1202
1225
  const got2 = await input.evaluate((el) => {
1203
- if (el.tagName.toLowerCase() === "textarea" || el.tagName.toLowerCase() === "input")
1226
+ if (el.tagName.toLowerCase() === 'textarea' ||
1227
+ el.tagName.toLowerCase() === 'input')
1204
1228
  return el.value;
1205
- return el.innerText || el.textContent || "";
1229
+ return el.innerText || el.textContent || '';
1206
1230
  });
1207
1231
  if (norm(got2) !== norm(text)) {
1208
1232
  throw new Error(this._t('ds.input_partial', {
@@ -1275,6 +1299,22 @@ export class DeepSeekBrowser {
1275
1299
  // Wait for the upload to finish (the attach preview to appear).
1276
1300
  await this.page.waitForTimeout(2000);
1277
1301
  }
1302
+ // Wait out a rate-limit pause, showing an animated countdown through
1303
+ // onSendPause so the operator sees the agent is alive (not frozen). Returns
1304
+ // true when the wait was cut short by Esc/Ctrl+C.
1305
+ async _waitRateLimit(totalSec) {
1306
+ const tick = (leftMs) => {
1307
+ const secs = Math.max(0, Math.ceil(leftMs / 1000));
1308
+ if (this.onSendPause) {
1309
+ try {
1310
+ this.onSendPause(secs);
1311
+ }
1312
+ catch { }
1313
+ }
1314
+ };
1315
+ tick(totalSec * 1000);
1316
+ return await this._sleepInterruptible(totalSec * 1000, tick);
1317
+ }
1278
1318
  // Pause between sends. Applied ONLY to agent messages
1279
1319
  // (tool-result, system-prompt) so we don't hit the rate limit.
1280
1320
  // User input is sent without delay.
@@ -1366,7 +1406,14 @@ export class DeepSeekBrowser {
1366
1406
  // the "new answer" check never fired — the source of the
1367
1407
  // ds.send_no_new_answer flapping.
1368
1408
  const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
1369
- this._askDebug('SEND agent=' + agent + ' len=' + prompt.length + ' beforeLen=' + beforeText.length + ' beforeHead=' + JSON.stringify(beforeText.slice(0, 60)));
1409
+ this._askDebug('SEND agent=' +
1410
+ agent +
1411
+ ' len=' +
1412
+ prompt.length +
1413
+ ' beforeLen=' +
1414
+ beforeText.length +
1415
+ ' beforeHead=' +
1416
+ JSON.stringify(beforeText.slice(0, 60)));
1370
1417
  await this._waitForSendSlot(agent);
1371
1418
  // Esc/Ctrl+C pressed during the pause — do not send anything.
1372
1419
  if (this._abort)
@@ -1459,7 +1506,12 @@ export class DeepSeekBrowser {
1459
1506
  const netStarted = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1460
1507
  if (changed || netStarted || bodyLen > startBodyLen) {
1461
1508
  started = true;
1462
- this._askDebug('STARTED changed=' + changed + ' netStarted=' + netStarted + ' bodyGrew=' + (bodyLen > startBodyLen));
1509
+ this._askDebug('STARTED changed=' +
1510
+ changed +
1511
+ ' netStarted=' +
1512
+ netStarted +
1513
+ ' bodyGrew=' +
1514
+ (bodyLen > startBodyLen));
1463
1515
  break;
1464
1516
  }
1465
1517
  // Fallback for an echo: the text equals beforeText, so it is the OLD
@@ -1480,7 +1532,18 @@ export class DeepSeekBrowser {
1480
1532
  settledTicks = 0;
1481
1533
  }
1482
1534
  lastStartCur = cur;
1483
- this._askDebug('START-loop curLen=' + cur.length + ' changed=' + changed + ' netStarted=' + netStarted + ' bodyLen=' + bodyLen + ' settled=' + settledTicks + ' generating=' + notGenerating);
1535
+ this._askDebug('START-loop curLen=' +
1536
+ cur.length +
1537
+ ' changed=' +
1538
+ changed +
1539
+ ' netStarted=' +
1540
+ netStarted +
1541
+ ' bodyLen=' +
1542
+ bodyLen +
1543
+ ' settled=' +
1544
+ settledTicks +
1545
+ ' generating=' +
1546
+ notGenerating);
1484
1547
  await this.page.waitForTimeout(300);
1485
1548
  }
1486
1549
  if (!started) {
@@ -1490,7 +1553,10 @@ export class DeepSeekBrowser {
1490
1553
  // previous tool call.
1491
1554
  const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1492
1555
  const fresh = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1493
- if (cur && cur.trim() && normText(cur) !== normText(beforeText) && !(await this._isGenerating())) {
1556
+ if (cur &&
1557
+ cur.trim() &&
1558
+ normText(cur) !== normText(beforeText) &&
1559
+ !(await this._isGenerating())) {
1494
1560
  return cur;
1495
1561
  }
1496
1562
  if (fresh) {
@@ -1533,31 +1599,41 @@ export class DeepSeekBrowser {
1533
1599
  const deadline = Date.now() + timeout;
1534
1600
  let last = '';
1535
1601
  let stable = 0;
1536
- let tick = 0;
1537
1602
  while (Date.now() < deadline) {
1538
1603
  if (this._abort) {
1539
1604
  return last || '(прервано пользователем)';
1540
1605
  }
1541
- // We check the limit toast not every tick but about once per 5 ticks,
1542
- // so we don't poke the DOM unnecessarily.
1543
- if (tick++ % 5 === 0) {
1606
+ // Rate-limit / server-busy toast. The old code only read the toasts
1607
+ // when `tick % 5 === 0` (once per ~2s), and the throw on the rate limit
1608
+ // made the server-busy branch unreachable. A toast that appears and
1609
+ // disappears between those checks was missed entirely, so the run
1610
+ // waited out the whole deadline and threw ds.send_no_new_answer. We now
1611
+ // read the toast on EVERY tick (a cheap, selector-scoped evaluate) and
1612
+ // classify both conditions independently.
1613
+ {
1544
1614
  const pageText = await this._readPageText();
1545
1615
  if (isRateLimitText(pageText)) {
1546
1616
  throw new RateLimitError(pageText.slice(0, 300));
1547
- if (isServerBusyText(pageText)) {
1548
- throw new ServerBusyError(pageText.slice(0, 300));
1549
- }
1617
+ }
1618
+ if (isServerBusyText(pageText)) {
1619
+ throw new ServerBusyError(pageText.slice(0, 300));
1550
1620
  }
1551
1621
  }
1552
1622
  const netFresh = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
1553
- const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1623
+ // The DOM may be empty while the answer is already in the network
1624
+ // capture (a resumed chat re-renders its history, the answer selector
1625
+ // may lag, or page.evaluate times out). In that case fall back to the
1626
+ // raw capture: it is the CURRENT answer and must not be ignored just
1627
+ // because the DOM has not caught up.
1628
+ let cur = await this._readLastAnswerTextCleanDom().catch(() => '');
1629
+ if (!cur && netFresh)
1630
+ cur = this._cleanAnswer(this._netCapture);
1554
1631
  // Ignore an "answer" that is identical to what was on the page BEFORE we
1555
1632
  // sent the message: that is the previous answer, not a new one. Returning
1556
1633
  // it would make the agent re-process the old tool call (or silently
1557
1634
  // stop). We keep waiting instead. A fresh network capture is exempt: it
1558
1635
  // belongs to the CURRENT send even if the DOM still shows the old text.
1559
- const isNew = !!cur &&
1560
- (netFresh || normText(cur) !== normText(beforeText));
1636
+ const isNew = !!cur && (netFresh || normText(cur) !== normText(beforeText));
1561
1637
  // An echo/stale answer equals beforeText, so isNew stays false and the
1562
1638
  // old loop waited until the full timeout — the "agent stopped after a
1563
1639
  // tool call" hang. If generation has clearly ENDED (no Stop button) and
@@ -1581,7 +1657,18 @@ export class DeepSeekBrowser {
1581
1657
  }
1582
1658
  if (isNew)
1583
1659
  last = cur;
1584
- this._askDebug('FIN-loop isNew=' + isNew + ' sameAsBefore=' + sameAsBefore + ' stable=' + stable + ' curLen=' + cur.length + ' lastLen=' + last.length + ' netFresh=' + netFresh);
1660
+ this._askDebug('FIN-loop isNew=' +
1661
+ isNew +
1662
+ ' sameAsBefore=' +
1663
+ sameAsBefore +
1664
+ ' stable=' +
1665
+ stable +
1666
+ ' curLen=' +
1667
+ cur.length +
1668
+ ' lastLen=' +
1669
+ last.length +
1670
+ ' netFresh=' +
1671
+ netFresh);
1585
1672
  await this.page.waitForTimeout(Math.max(0, this.stabilityDelayMs));
1586
1673
  }
1587
1674
  if (last && (normText(last) !== normText(beforeText) || this._netCapture)) {
@@ -1593,7 +1680,10 @@ export class DeepSeekBrowser {
1593
1680
  if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
1594
1681
  return this._netCapture;
1595
1682
  }
1596
- this._askDebug('THROW no-new-answer lastLen=' + last.length + ' beforeLen=' + beforeText.length);
1683
+ this._askDebug('THROW no-new-answer lastLen=' +
1684
+ last.length +
1685
+ ' beforeLen=' +
1686
+ beforeText.length);
1597
1687
  throw new Error(this._t('ds.send_no_new_answer'));
1598
1688
  }
1599
1689
  async dumpDom(filePath) {
package/dist/commands.js CHANGED
@@ -7,7 +7,10 @@ const CR = String.fromCharCode(13);
7
7
  // ---------- /diff ----------
8
8
  export function formatDiff(diffText, opts = {}) {
9
9
  const maxLines = opts.maxLines ?? 200;
10
- const trimmed = String(diffText ?? '').split(CR + NL).join(NL).trimEnd();
10
+ const trimmed = String(diffText ?? '')
11
+ .split(CR + NL)
12
+ .join(NL)
13
+ .trimEnd();
11
14
  if (!trimmed)
12
15
  return '(no changes)';
13
16
  const lines = trimmed.split(NL);
@@ -17,7 +20,9 @@ export function formatDiff(diffText, opts = {}) {
17
20
  const rest = lines.length - maxLines;
18
21
  return (head +
19
22
  NL +
20
- '... [' + rest + ' more lines, use Bash git diff for the full output]');
23
+ '... [' +
24
+ rest +
25
+ ' more lines, use Bash git diff for the full output]');
21
26
  }
22
27
  export function diffGitArgs(staged = false) {
23
28
  return staged ? 'git diff --staged' : 'git diff';
@@ -66,7 +66,9 @@ export function runConfigMenu(opts) {
66
66
  }
67
67
  const selected = i === cursor;
68
68
  const marker = selected ? theme.prompt('\u276f ') : ' ';
69
- const label = selected ? theme.prompt(f.labelKey ? t(f.labelKey) : f.path) : t(f.labelKey || f.path);
69
+ const label = selected
70
+ ? theme.prompt(f.labelKey ? t(f.labelKey) : f.path)
71
+ : t(f.labelKey || f.path);
70
72
  const val = displayValue(f, get(f.path), t);
71
73
  const valText = selected ? theme.assistant(val) : theme.dim(val);
72
74
  const pathText = selected ? theme.dim(' ' + f.path) : '';