zames_pro 2.90.18 → 2.92.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.92.0] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - Internal improvements.
15
+
16
+ ## [2.91.0] - 2026-11-01
17
+
18
+ ### Added
19
+
20
+ - **retry:** infinite incremental retries with escalating backoff
21
+
22
+ ### Fixed
23
+
24
+ - **qwen,glm:** headless login probe and headed runs
25
+ - **ui:** same no-flicker phase rule for qwen and glm
26
+
10
27
  ## [2.90.18] - 2026-10-30
11
28
 
12
29
  ### Fixed
@@ -1,3 +1,4 @@
1
+ import { backoffWaitMs } from './retry-backoff.js';
1
2
  import { buildSystemPrompt } from './system-prompt.js';
2
3
  import { isContextLimitText } from './browser-pure.js';
3
4
  import { providerLabel as providerHumanLabel } from './providers.js';
@@ -12,7 +13,7 @@ import { shouldRunDiagnostics, runDiagnostics, } from './diagnostics.js';
12
13
  // The tools that WRITE/EDIT a file (and take a `path`). The completion nudge
13
14
  // only cares about these — `Bash`/`Git*` in MUTATING_TOOLS are not edits.
14
15
  const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'ApplyPatch']);
15
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = DEFAULT_LOCALE, askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, onContextLimit = null, onProviderExhausted = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
16
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = DEFAULT_LOCALE, askDeadlineMs = 240_000, maxAfterToolRetries = 6, infiniteRetries = true, maxRetryWaitMs = 30 * 60_000, onAutoCompact = null, onContextLimit = null, onProviderExhausted = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, onAskUser = null, }) {
16
17
  // Resolve the hook config ONCE per task: a read per tool call would be
17
18
  // wasteful, and a mid-task edit of hooks.json is not something to chase.
18
19
  // `undefined` means "read .zames/hooks.json"; an explicit null disables
@@ -292,6 +293,15 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
292
293
  // The nudge is sent at most ONCE per task: it is a hint, not a guard.
293
294
  let completionNudgeSent = false;
294
295
  const MAX_AFTER_TOOL_RETRIES = Math.max(0, Math.floor(maxAfterToolRetries));
296
+ // The watchdog and the transient send families share ONE backoff
297
+ // (src/retry-backoff.ts): with infiniteRetries on (default) the wait grows
298
+ // 5s -> 10s -> ... up to maxRetryWaitMs instead of giving up.
299
+ const watchdogBackoff = {
300
+ infiniteRetries,
301
+ maxRetries: MAX_AFTER_TOOL_RETRIES,
302
+ baseWaitMs: 5000,
303
+ maxWaitMs: maxRetryWaitMs,
304
+ };
295
305
  // One-time hint when the context nears full while auto-compact is OFF:
296
306
  // the operator is told about /compact instead of silently degrading. Set
297
307
  // once per task so the terminal is not spammed.
@@ -384,10 +394,13 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
384
394
  // single hiccup from a genuine stall (the budget used to be invisible).
385
395
  safeWarning(tr('ds.answer_timeout', {
386
396
  sec: Math.round(askDeadlineMs / 1000),
387
- attempt: Math.min(afterToolRetries + 1, MAX_AFTER_TOOL_RETRIES),
388
- max: MAX_AFTER_TOOL_RETRIES,
397
+ attempt: infiniteRetries
398
+ ? afterToolRetries + 1
399
+ : Math.min(afterToolRetries + 1, MAX_AFTER_TOOL_RETRIES),
400
+ max: infiniteRetries ? '∞' : MAX_AFTER_TOOL_RETRIES,
389
401
  }));
390
- if (afterToolRetries < MAX_AFTER_TOOL_RETRIES) {
402
+ const watchdogWaitMs = backoffWaitMs(watchdogBackoff, afterToolRetries + 1);
403
+ if (infiniteRetries || afterToolRetries < MAX_AFTER_TOOL_RETRIES) {
391
404
  afterToolRetries++;
392
405
  // The retry used to `continue` with the UNCHANGED `message`: a bare
393
406
  // re-send of the same task text. The model then saw the task arrive a
@@ -401,7 +414,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
401
414
  'no text before or after, for example: ' +
402
415
  '{"tool": "Bash", "args": {"command": "..."}}. ' +
403
416
  'If the task is really done — call respond with the final message.';
404
- await new Promise((r) => setTimeout(r, 1500));
417
+ await new Promise((r) => setTimeout(r, watchdogWaitMs));
405
418
  continue;
406
419
  }
407
420
  transcript?.log('ask_timeout_exhausted', {
package/dist/ask-retry.js CHANGED
@@ -1,49 +1,10 @@
1
- // N1 stage 4: the retry POLICY of a provider's `ask()` as a PURE function.
1
+ // N1 stage 4: the retry POLICY of a provider's ask() as a PURE function.
2
2
  //
3
- // The three providers ran the same loop — call `_askOnce`, classify the thrown
4
- // error into a family (rate limit / server busy / incomplete / context limit /
5
- // provider-specific fatal), retry that family within its own budget, otherwise
6
- // fall through to a generic attempt counter — and each copy had its own order
7
- // of checks and its own wording. The loop itself (sends, throttling, aborts)
8
- // stays in the browser class; only the DECISION is here, so every branch is
9
- // unit-tested without a browser.
10
- /**
11
- * Decide what to do after a failed attempt.
12
- *
13
- * `classify` maps the thrown error to a family; it lives in the provider
14
- * because the backend-specific markers (guest limit, age gate, captcha) are
15
- * provider knowledge. A `null` classification falls through to the generic
16
- * attempt budget.
17
- */
18
- export function decideRetry(family, state, budgets, waits) {
19
- switch (family) {
20
- case 'fatal':
21
- return { kind: 'give-up', family };
22
- case 'context-limit':
23
- if (state.contextLimitRetries >= budgets.maxContextLimitRetries) {
24
- return { kind: 'give-up', family };
25
- }
26
- return { kind: 'compact', family };
27
- case 'rate-limit':
28
- if (state.rateLimitRetries >= budgets.maxRateLimitRetries) {
29
- return { kind: 'give-up', family };
30
- }
31
- return { kind: 'wait', family, waitMs: waits.rateLimitMs };
32
- case 'server-busy':
33
- if (state.serverBusyRetries >= budgets.maxServerBusyRetries) {
34
- return { kind: 'give-up', family };
35
- }
36
- return { kind: 'wait', family, waitMs: waits.serverBusyMs };
37
- case 'incomplete':
38
- if (state.incompleteRetries >= budgets.maxIncompleteRetries) {
39
- return { kind: 'give-up', family };
40
- }
41
- return { kind: 'wait', family, waitMs: waits.incompleteMs };
42
- default:
43
- // A generic failure is retried while the TOTAL attempt budget lasts.
44
- if (state.attempt >= budgets.askRetries - 1) {
45
- return { kind: 'give-up', family: 'generic' };
46
- }
47
- return { kind: 'generic', waitMs: 2000 * (state.attempt + 1) };
48
- }
49
- }
3
+ // The real implementation now lives in src/retry-policy.ts (it grew the
4
+ // incremental-backoff mode); this module stays as the historical import
5
+ // point so the providers and the tests do not churn.
6
+ //
7
+ // WHY the split: the DECISION (family -> wait / compact / give-up) is pure
8
+ // and unit-tested, while the loop (sends, throttling, aborts) stays in the
9
+ // browser class. A new backend only supplies the `classify` function.
10
+ export { decideRetry } from './retry-policy.js';
@@ -53,8 +53,14 @@ export async function interactiveBrowserLogin(deps) {
53
53
  await deps.persistSession().catch(() => { });
54
54
  return 'auto';
55
55
  }
56
- // 3) A guest is usable and no window is needed.
57
- if (deps.guestAllowed)
56
+ // 3) A guest is usable and no window is needed — but ONLY on a non-
57
+ // interactive run. MEASURED live (Qwen): a saved session that the
58
+ // probe cannot read yet (the app hydrates slowly, or the session lives
59
+ // in a cookie the probe does not look at) turned into "guest" while an
60
+ // OPERATOR was right there, and the run then died on the age gate.
61
+ // With a terminal we open the window instead: the human either sees
62
+ // his session already there or signs in once.
63
+ if (deps.guestAllowed && !deps.interactive)
58
64
  return 'guest';
59
65
  // 4) Non-interactive: fail fast instead of hanging on a window nobody sees.
60
66
  if (!deps.interactive)
package/dist/browser.js CHANGED
@@ -24,6 +24,7 @@ export { STOP_NAME_RE, CONTINUE_NAME_RE };
24
24
  // module is unchanged (tests and other modules import these names from
25
25
  // './browser.js').
26
26
  import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, needsHeadlessUaFix, writeAuthMarker, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitNotice, isContextLimitText, normText, cleanAnswer, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
27
+ import { backoffWaitMs } from './retry-backoff.js';
27
28
  export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, isContextLimitText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, ContextLimitError, };
28
29
  export class DeepSeekBrowser {
29
30
  providerName = 'deepseek';
@@ -32,6 +33,8 @@ export class DeepSeekBrowser {
32
33
  channel;
33
34
  answerTimeoutMs;
34
35
  askRetries;
36
+ infiniteRetries;
37
+ maxRetryWaitMs;
35
38
  stabilityChecks;
36
39
  stabilityDelayMs;
37
40
  minSendIntervalMs;
@@ -178,12 +181,14 @@ export class DeepSeekBrowser {
178
181
  _windowClosed;
179
182
  /** Session cookies captured before a relaunch (no `expires` -> in-memory). */
180
183
  _carryCookies;
181
- constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, autoContinue = true, continueMinGapMs = 1500, loginTimeoutMs = 600000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, onMfaPrompt, userAgent, } = {}) {
184
+ constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, infiniteRetries = true, maxRetryWaitMs = 1800000, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, thinkingExtraMs = 2000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, autoContinue = true, continueMinGapMs = 1500, loginTimeoutMs = 600000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, onMfaPrompt, userAgent, } = {}) {
182
185
  this.headless = headless;
183
186
  this.debug = debug;
184
187
  this.channel = channel;
185
188
  this.answerTimeoutMs = answerTimeoutMs;
186
189
  this.askRetries = askRetries;
190
+ this.infiniteRetries = infiniteRetries;
191
+ this.maxRetryWaitMs = maxRetryWaitMs;
187
192
  this.stabilityChecks = stabilityChecks;
188
193
  this.stabilityDelayMs = stabilityDelayMs;
189
194
  this.minSendIntervalMs = minSendIntervalMs;
@@ -1690,6 +1695,15 @@ export class DeepSeekBrowser {
1690
1695
  async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
1691
1696
  let lastErr = null;
1692
1697
  let attempt = 0;
1698
+ // The transient families share ONE incremental backoff (retry-backoff.ts):
1699
+ // with infiniteRetries on, the wait escalates instead of running out of
1700
+ // attempts while the server is still asking us to wait.
1701
+ const backoffCfg = {
1702
+ infiniteRetries: this.infiniteRetries,
1703
+ maxRetries: Math.max(1, this.maxRateLimitRetries),
1704
+ baseWaitMs: 5000,
1705
+ maxWaitMs: this.maxRetryWaitMs,
1706
+ };
1693
1707
  let rateLimitRetries = 0;
1694
1708
  let serverBusyRetries = 0;
1695
1709
  // N8: the fallback switch is tried ONCE per ask(), not per family.
@@ -1723,9 +1737,9 @@ export class DeepSeekBrowser {
1723
1737
  // The answer has settled (the finish loop returned it): mark the
1724
1738
  // lifecycle state so the status line shows "settled" instead of
1725
1739
  // still looking like generation.
1726
- if (answer !== '(прервано пользователем)') {
1727
- this._emitSendState('settled');
1728
- }
1740
+ // No 'settled' badge here: the next send flips the state right
1741
+ // back to 'generating', and that add/remove pair is what makes the
1742
+ // status row flicker. 'generating' is cleared by the caller.
1729
1743
  return answer;
1730
1744
  }
1731
1745
  catch (e) {
@@ -1735,7 +1749,8 @@ export class DeepSeekBrowser {
1735
1749
  // context is lost). The waits don't consume the regular ask() attempts.
1736
1750
  if (e instanceof RateLimitError) {
1737
1751
  rateLimitRetries++;
1738
- if (rateLimitRetries > this.maxRateLimitRetries) {
1752
+ if (!this.infiniteRetries &&
1753
+ rateLimitRetries > this.maxRateLimitRetries) {
1739
1754
  this._notice(theme.error(this._t('ds.rate_limit_give_up', {
1740
1755
  attempt: rateLimitRetries,
1741
1756
  min: Math.ceil(this.rateLimitWaitMs / 60000),
@@ -1743,16 +1758,17 @@ export class DeepSeekBrowser {
1743
1758
  throw e;
1744
1759
  }
1745
1760
  this._notice(theme.warn(this._t('ds.rate_limit_wait', {
1746
- min: Math.ceil(this.rateLimitWaitMs / 60000),
1761
+ min: Math.ceil(backoffWaitMs(backoffCfg, rateLimitRetries) / 60000),
1747
1762
  attempt: rateLimitRetries,
1748
- max: this.maxRateLimitRetries,
1763
+ max: this.infiniteRetries ? '∞' : this.maxRateLimitRetries,
1749
1764
  })));
1750
1765
  // Interruptible: Esc/Ctrl+C must cancel this long wait too,
1751
1766
  // otherwise "stop" stays frozen for up to 5 minutes.
1752
1767
  // The wait is ANIMATED through onSendPause (a live countdown), so the
1753
1768
  // operator sees the agent is waiting on the rate limit instead of a
1754
1769
  // frozen spinner. `_lastSentAt` is bumped so the countdown renders.
1755
- const waitSec = Math.ceil(this.rateLimitWaitMs / 1000);
1770
+ const waitMs = backoffWaitMs(backoffCfg, rateLimitRetries);
1771
+ const waitSec = Math.ceil(waitMs / 1000);
1756
1772
  const aborted = await this._waitRateLimit(waitSec);
1757
1773
  if (aborted)
1758
1774
  return '(прервано пользователем)';
@@ -1764,18 +1780,20 @@ export class DeepSeekBrowser {
1764
1780
  // explanation of what DeepSeek is doing.
1765
1781
  if (e instanceof ServerBusyError) {
1766
1782
  serverBusyRetries++;
1767
- if (serverBusyRetries > this.maxServerBusyRetries) {
1783
+ if (!this.infiniteRetries &&
1784
+ serverBusyRetries > this.maxServerBusyRetries) {
1768
1785
  this._notice(theme.error(this._t('ds.server_busy_give_up', {
1769
1786
  attempt: serverBusyRetries,
1770
1787
  })));
1771
1788
  throw e;
1772
1789
  }
1790
+ const busyWaitMs = backoffWaitMs(backoffCfg, serverBusyRetries);
1773
1791
  this._notice(theme.warn(this._t('ds.server_busy_wait', {
1774
- sec: Math.ceil(this.serverBusyWaitMs / 1000),
1792
+ sec: Math.ceil(busyWaitMs / 1000),
1775
1793
  attempt: serverBusyRetries,
1776
- max: this.maxServerBusyRetries,
1794
+ max: this.infiniteRetries ? '∞' : this.maxServerBusyRetries,
1777
1795
  })));
1778
- const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
1796
+ const aborted = await this._sleepInterruptible(busyWaitMs);
1779
1797
  if (aborted)
1780
1798
  return '(прервано пользователем)';
1781
1799
  continue;
@@ -1787,7 +1805,8 @@ export class DeepSeekBrowser {
1787
1805
  // button does) instead of waiting for an answer that will never come.
1788
1806
  if (e instanceof GenerationIncompleteError) {
1789
1807
  incompleteRetries++;
1790
- if (incompleteRetries > this.maxIncompleteRetries) {
1808
+ if (!this.infiniteRetries &&
1809
+ incompleteRetries > this.maxIncompleteRetries) {
1791
1810
  this._notice(theme.error(this._t('ds.incomplete_give_up', {
1792
1811
  attempt: incompleteRetries,
1793
1812
  })));
@@ -1795,9 +1814,10 @@ export class DeepSeekBrowser {
1795
1814
  }
1796
1815
  this._notice(theme.warn(this._t('ds.incomplete_retry', {
1797
1816
  attempt: incompleteRetries,
1798
- max: this.maxIncompleteRetries,
1817
+ max: this.infiniteRetries ? '∞' : this.maxIncompleteRetries,
1799
1818
  })));
1800
- const aborted = await this._sleepInterruptible(this.incompleteWaitMs);
1819
+ const incompleteWaitMs = backoffWaitMs(backoffCfg, incompleteRetries);
1820
+ const aborted = await this._sleepInterruptible(incompleteWaitMs);
1801
1821
  if (aborted)
1802
1822
  return '(прервано пользователем)';
1803
1823
  continue;
@@ -2025,7 +2045,12 @@ export class DeepSeekBrowser {
2025
2045
  };
2026
2046
  this._emitSendState('paused');
2027
2047
  tick(totalSec * 1000);
2028
- return await this._sleepInterruptible(totalSec * 1000, tick);
2048
+ const aborted = await this._sleepInterruptible(totalSec * 1000, tick);
2049
+ // The wait is over and the send is about to go out: leave the badge on
2050
+ // 'generating' ('paused' left it showing a paused dot forever).
2051
+ if (!aborted)
2052
+ this._emitSendState('generating');
2053
+ return aborted;
2029
2054
  }
2030
2055
  // Pause between sends. Applied ONLY to agent messages
2031
2056
  // (tool-result, system-prompt) so we don't hit the rate limit.
@@ -2075,8 +2100,10 @@ export class DeepSeekBrowser {
2075
2100
  report(gap);
2076
2101
  this._emitSendState('paused');
2077
2102
  await this._sleepInterruptible(gap, report);
2103
+ // Back to GENERATING, not settled: nothing has been sent yet, so a
2104
+ // "✓ done" badge here showed up before the question even left the page.
2078
2105
  if (!this._abort && !this._askCancelled)
2079
- this._emitSendState('settled');
2106
+ this._emitSendState('generating');
2080
2107
  }
2081
2108
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
2082
2109
  // Returns true if the sleep was cut short by _abort.
@@ -22,6 +22,8 @@ export const BROWSER_LIVE_CONFIG = {
22
22
  'browser.continueMinGapMs': 'continueMinGapMs',
23
23
  'browser.askDeadlineMs': 'askDeadlineMs',
24
24
  'browser.maxAfterToolRetries': 'maxAfterToolRetries',
25
+ 'browser.infiniteRetries': 'infiniteRetries',
26
+ 'browser.maxRetryWaitMs': 'maxRetryWaitMs',
25
27
  };
26
28
  /** Push a browser.* change onto the live instance. Returns true when a
27
29
  * field was written (false = not a browser timing knob, or absent on this
@@ -33,6 +35,12 @@ export function applyBrowserLiveConfig(path, value, browser) {
33
35
  // A timing value must be a FINITE NON-NEGATIVE number: a NaN reached a live
34
36
  // field (a timer with NaN fires immediately) and a negative one is nonsense.
35
37
  // Reject the value instead of corrupting the field.
38
+ // A BOOLEAN toggle (browser.infiniteRetries) is a different shape: accept
39
+ // it as-is, otherwise every live toggle of it would be rejected here.
40
+ if (typeof value === 'boolean') {
41
+ browser[field] = value;
42
+ return true;
43
+ }
36
44
  if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
37
45
  return false;
38
46
  }
@@ -130,6 +130,22 @@ export const CONFIG_SCHEMA = [
130
130
  topKey: 'cfg.top.browser',
131
131
  groupKey: 'cfg.group.chat',
132
132
  },
133
+ {
134
+ path: 'browser.infiniteRetries',
135
+ type: 'boolean',
136
+ labelKey: 'cfg.f.browser_infiniteRetries',
137
+ topKey: 'cfg.top.browser',
138
+ groupKey: 'cfg.group.recovery',
139
+ },
140
+ {
141
+ path: 'browser.maxRetryWaitMs',
142
+ type: 'number',
143
+ min: 0,
144
+ max: 7200000,
145
+ labelKey: 'cfg.f.browser_maxRetryWaitMs',
146
+ topKey: 'cfg.top.browser',
147
+ groupKey: 'cfg.group.recovery',
148
+ },
133
149
  {
134
150
  path: 'browser.deepThinking',
135
151
  type: 'boolean',
package/dist/config.js CHANGED
@@ -110,6 +110,13 @@ export const DEFAULTS = {
110
110
  // not waited out. 240s used to mean a 4-minute spinner per dead turn.
111
111
  askDeadlineMs: 120000,
112
112
  maxAfterToolRetries: 6,
113
+ // Infinite incremental retries for the transient families (rate limit,
114
+ // server busy, truncated turn, watchdog): the wait escalates 5m -> 10m
115
+ // -> 20m -> the ceiling instead of a hard attempt cap. Set false to get
116
+ // the old counted budget (maxRateLimitRetries / maxIncompleteRetries).
117
+ infiniteRetries: true,
118
+ // Ceiling for ONE retry wait (ms). 0 = no ceiling.
119
+ maxRetryWaitMs: 1800000,
113
120
  // Auto-compact is ON by default: a provider's context window is small and
114
121
  // per-model (GLM: 32k-128k), and running out of it mid-task is a hard stop
115
122
  // that loses the work, while a compaction at a safe seam only costs one
@@ -1,3 +1,4 @@
1
+ import { backoffWaitMs } from './retry-backoff.js';
1
2
  // GLM (chat.z.ai) provider — the third chat backend zames can drive.
2
3
  //
3
4
  // WHY a separate class (the same reason QwenBrowser exists): the backends
@@ -57,6 +58,8 @@ export class GlmBrowser {
57
58
  channel;
58
59
  answerTimeoutMs;
59
60
  askRetries;
61
+ infiniteRetries;
62
+ maxRetryWaitMs;
60
63
  stabilityChecks;
61
64
  stabilityDelayMs;
62
65
  minSendIntervalMs;
@@ -131,12 +134,14 @@ export class GlmBrowser {
131
134
  * removes most of the wait. Empty = leave the site's default.
132
135
  */
133
136
  reasoningEffort;
134
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 300000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 0, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = false, auth, locale, onAuthSave, onLoginPrompt, disableAgentRuntime = true, reasoningEffort = 'low', sendTelemetry = true, logsDir, loginTimeoutMs = 600000, userAgent, } = {}) {
137
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 300000, askRetries = 3, infiniteRetries = true, maxRetryWaitMs = 1800000, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 0, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = false, auth, locale, onAuthSave, onLoginPrompt, disableAgentRuntime = true, reasoningEffort = 'low', sendTelemetry = true, logsDir, loginTimeoutMs = 600000, userAgent, } = {}) {
135
138
  this.headless = headless;
136
139
  this.debug = debug;
137
140
  this.channel = channel;
138
141
  this.answerTimeoutMs = answerTimeoutMs;
139
142
  this.askRetries = askRetries;
143
+ this.infiniteRetries = infiniteRetries;
144
+ this.maxRetryWaitMs = maxRetryWaitMs;
140
145
  this.stabilityChecks = stabilityChecks;
141
146
  this.stabilityDelayMs = stabilityDelayMs;
142
147
  this.minSendIntervalMs = minSendIntervalMs;
@@ -653,7 +658,7 @@ export class GlmBrowser {
653
658
  */
654
659
  async waitForLogin() {
655
660
  const interactive = loginIsInteractive({
656
- isTTY: !!process.stdin.isTTY,
661
+ isTTY: !!process.stdin.isTTY || !this.headless,
657
662
  oneShot: false,
658
663
  scheduled: false,
659
664
  env: process.env,
@@ -1426,6 +1431,10 @@ export class GlmBrowser {
1426
1431
  report(gap);
1427
1432
  this._emitSendState('paused');
1428
1433
  await this._sleepInterruptible(gap, report);
1434
+ // Back to GENERATING: nothing has been sent yet, so a "done" badge here
1435
+ // would show up before the question even left the page.
1436
+ if (!this._abort && !this._askCancelled)
1437
+ this._emitSendState('generating');
1429
1438
  }
1430
1439
  async _sleepInterruptible(ms, onTick) {
1431
1440
  const step = 100;
@@ -1455,6 +1464,12 @@ export class GlmBrowser {
1455
1464
  async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
1456
1465
  let lastErr = null;
1457
1466
  let attempt = 0;
1467
+ const backoffCfg = {
1468
+ infiniteRetries: this.infiniteRetries,
1469
+ maxRetries: Math.max(1, this.maxRateLimitRetries),
1470
+ baseWaitMs: 5000,
1471
+ maxWaitMs: this.maxRetryWaitMs,
1472
+ };
1458
1473
  let rateLimitRetries = 0;
1459
1474
  let serverBusyRetries = 0;
1460
1475
  let incompleteRetries = 0;
@@ -1465,16 +1480,16 @@ export class GlmBrowser {
1465
1480
  let fallbackAttempted = false;
1466
1481
  this._askInFlight++;
1467
1482
  try {
1468
- while (attempt < this.askRetries) {
1483
+ while (this.infiniteRetries || attempt < this.askRetries) {
1469
1484
  try {
1470
1485
  const answer = await this._askOnce(prompt, {
1471
1486
  timeout,
1472
1487
  agent,
1473
1488
  attachments,
1474
1489
  });
1475
- if (answer !== '(прервано пользователем)') {
1476
- this._emitSendState('settled');
1477
- }
1490
+ // No transient 'settled' here: the next send flips the state
1491
+ // back to 'generating' and that add/remove pair is the status-row
1492
+ // flicker (same fix as the DeepSeek provider).
1478
1493
  return answer;
1479
1494
  }
1480
1495
  catch (e) {
@@ -1517,42 +1532,45 @@ export class GlmBrowser {
1517
1532
  }
1518
1533
  if (/rate limit|too frequent/i.test(msg)) {
1519
1534
  this.sendRuntime.onOutcome('rate_limit', { errorText: msg });
1520
- if (rateLimitRetries >= this.maxRateLimitRetries)
1535
+ if (!this.infiniteRetries &&
1536
+ rateLimitRetries >= this.maxRateLimitRetries)
1521
1537
  break;
1522
1538
  rateLimitRetries++;
1523
1539
  this._notice(this._t('glm.rate_limit_wait', {
1524
- min: Math.ceil(this.rateLimitWaitMs / 60000),
1540
+ min: Math.ceil(backoffWaitMs(backoffCfg, rateLimitRetries) / 60000),
1525
1541
  attempt: rateLimitRetries,
1526
- max: this.maxRateLimitRetries,
1542
+ max: this.infiniteRetries ? '∞' : this.maxRateLimitRetries,
1527
1543
  }));
1528
- if (await this._sleepInterruptible(this.rateLimitWaitMs))
1544
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, rateLimitRetries)))
1529
1545
  return '(прервано пользователем)';
1530
1546
  continue;
1531
1547
  }
1532
1548
  if (e instanceof GlmServerBusyError || /server busy/i.test(msg)) {
1533
1549
  this.sendRuntime.onOutcome('busy', { errorText: msg });
1534
- if (serverBusyRetries >= this.maxServerBusyRetries)
1550
+ if (!this.infiniteRetries &&
1551
+ serverBusyRetries >= this.maxServerBusyRetries)
1535
1552
  break;
1536
1553
  serverBusyRetries++;
1537
1554
  this._notice(this._t('glm.server_busy_wait', {
1538
- sec: Math.ceil(this.serverBusyWaitMs / 1000),
1555
+ sec: Math.ceil(backoffWaitMs(backoffCfg, serverBusyRetries) / 1000),
1539
1556
  attempt: serverBusyRetries,
1540
- max: this.maxServerBusyRetries,
1557
+ max: this.infiniteRetries ? '∞' : this.maxServerBusyRetries,
1541
1558
  }));
1542
- if (await this._sleepInterruptible(this.serverBusyWaitMs))
1559
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, serverBusyRetries)))
1543
1560
  return '(прервано пользователем)';
1544
1561
  continue;
1545
1562
  }
1546
1563
  if (e instanceof GlmIncompleteError) {
1547
1564
  this.sendRuntime.onOutcome('incomplete', { errorText: msg });
1548
- if (incompleteRetries >= this.maxIncompleteRetries)
1565
+ if (!this.infiniteRetries &&
1566
+ incompleteRetries >= this.maxIncompleteRetries)
1549
1567
  break;
1550
1568
  incompleteRetries++;
1551
1569
  this._notice(this._t('glm.incomplete_retry', {
1552
1570
  attempt: incompleteRetries,
1553
- max: this.maxIncompleteRetries,
1571
+ max: this.infiniteRetries ? '∞' : this.maxIncompleteRetries,
1554
1572
  }));
1555
- if (await this._sleepInterruptible(this.incompleteWaitMs))
1573
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, incompleteRetries)))
1556
1574
  return '(прервано пользователем)';
1557
1575
  continue;
1558
1576
  }
@@ -230,6 +230,14 @@ export const configMessages = {
230
230
  ru: 'Повторов после таймаута',
231
231
  en: 'Retries after a timeout',
232
232
  },
233
+ 'cfg.f.browser_infiniteRetries': {
234
+ ru: 'Повторять бесконечно (с растущей паузой)',
235
+ en: 'Retry forever (with an escalating wait)',
236
+ },
237
+ 'cfg.f.browser_maxRetryWaitMs': {
238
+ ru: 'Потолок одной паузы перед повтором, мс',
239
+ en: 'Ceiling for one retry wait, ms',
240
+ },
233
241
  'cfg.f.browser_continueMinGapMs': {
234
242
  ru: 'Мин. пауза перед нажатием Continue, мс',
235
243
  en: 'Min gap before a Continue click, ms',
package/dist/index.js CHANGED
@@ -1362,6 +1362,8 @@ async function main() {
1362
1362
  maxSubagents: config.browser.maxSubagents,
1363
1363
  askDeadlineMs: providerAskDeadlineMs(config),
1364
1364
  maxAfterToolRetries: config.browser.maxAfterToolRetries,
1365
+ infiniteRetries: config.browser.infiniteRetries,
1366
+ maxRetryWaitMs: config.browser.maxRetryWaitMs,
1365
1367
  // An `ask` permission rule must also gate the subagent: otherwise a
1366
1368
  // guarded tool (e.g. a dangerous Bash command) would run inside the
1367
1369
  // subagent WITHOUT the operator's confirmation.
@@ -28,6 +28,7 @@ import { theme } from './theme.js';
28
28
  import { recoverFromContextLimit } from './context-limit.js';
29
29
  import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
30
30
  import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, needsHeadlessUaFix, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
31
+ import { backoffWaitMs } from './retry-backoff.js';
31
32
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
32
33
  import { probeUiHealth, } from './ui-health.js';
33
34
  import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
@@ -71,6 +72,8 @@ export class QwenBrowser {
71
72
  channel;
72
73
  answerTimeoutMs;
73
74
  askRetries;
75
+ infiniteRetries;
76
+ maxRetryWaitMs;
74
77
  stabilityChecks;
75
78
  stabilityDelayMs;
76
79
  minSendIntervalMs;
@@ -148,12 +151,14 @@ export class QwenBrowser {
148
151
  onContextLimit;
149
152
  /** N8: switch to an authorized fallback provider (see agent-loop). */
150
153
  onProviderExhausted = null;
151
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
154
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, infiniteRetries = true, maxRetryWaitMs = 1800000, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, maxContextLimitRetries = 1, onContextLimit, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
152
155
  this.headless = headless;
153
156
  this.debug = debug;
154
157
  this.channel = channel;
155
158
  this.answerTimeoutMs = answerTimeoutMs;
156
159
  this.askRetries = askRetries;
160
+ this.infiniteRetries = infiniteRetries;
161
+ this.maxRetryWaitMs = maxRetryWaitMs;
157
162
  this.stabilityChecks = stabilityChecks;
158
163
  this.stabilityDelayMs = stabilityDelayMs;
159
164
  this.minSendIntervalMs = minSendIntervalMs;
@@ -586,29 +591,44 @@ export class QwenBrowser {
586
591
  async probeLogin() {
587
592
  if (!this.page)
588
593
  return 'unknown';
589
- try {
590
- const r = await this.page.evaluate(() => {
591
- const hasToken = (() => {
594
+ // MEASURED live: on a freshly navigated page the React app has not
595
+ // hydrated yet, so localStorage is still EMPTY and the guest CTA is
596
+ // painted. That made a genuinely signed-in session report "out"; the
597
+ // headless run continued as a GUEST and every send hit Qwen's
598
+ // "Confirm your age" gate. Wait for the app to write its tokens
599
+ // (`token` / `qwen_access_token_state`, seen on the live profile).
600
+ const hydrationDeadline = Date.now() + 20_000;
601
+ while (Date.now() < hydrationDeadline) {
602
+ try {
603
+ const hasToken = await this.page.evaluate(() => {
592
604
  try {
593
- return !!localStorage.getItem('token');
605
+ return !!(localStorage.getItem('token') ||
606
+ localStorage.getItem('qwen_access_token_state'));
594
607
  }
595
608
  catch {
596
609
  return false;
597
610
  }
598
- })();
611
+ });
612
+ if (hasToken)
613
+ return 'in';
614
+ }
615
+ catch {
616
+ // the page may be mid-navigation: retry on the next tick
617
+ }
618
+ await this.page.waitForTimeout(500);
619
+ }
620
+ try {
621
+ const signInVisible = await this.page.evaluate(() => {
599
622
  const body = document.body ? document.body.innerText : '';
600
- const signInVisible = /\b(log ?in|sign ?in)\b/i.test(body);
601
- return { hasToken, signInVisible };
623
+ return /\b(log ?in|sign ?in)\b/i.test(body);
602
624
  });
603
- if (r.hasToken)
604
- return 'in';
605
- if (r.signInVisible)
625
+ if (signInVisible)
606
626
  return 'out';
607
- return 'unknown';
608
627
  }
609
628
  catch {
610
- return 'unknown';
629
+ // fall through to unknown
611
630
  }
631
+ return 'unknown';
612
632
  }
613
633
  /**
614
634
  * A human signs in inside a VISIBLE window (a one-time code cannot be typed
@@ -769,7 +789,10 @@ export class QwenBrowser {
769
789
  // guest, so no error) the guest notice. The visible window and the return
770
790
  // to headless are the SHARED interactiveBrowserLogin path.
771
791
  const interactive = loginIsInteractive({
772
- isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
792
+ // A HEADED run IS interactive: the visible window is exactly what the
793
+ // human needs for the one-time code, even when stdin is a pipe (a smoke
794
+ // script). Requiring a TTY there failed the run with LoginRequired.
795
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY) || !this.headless,
773
796
  oneShot: false,
774
797
  scheduled: false,
775
798
  env: process.env,
@@ -811,15 +834,19 @@ export class QwenBrowser {
811
834
  now: () => Date.now(),
812
835
  interactive,
813
836
  stayHeaded: !this.headless,
814
- // A guest is usable, but a real session is strongly recommended; the
815
- // notice tells the operator why.
816
- guestAllowed: true,
837
+ // A guest is usable ONLY when nobody can sign in: with a terminal the
838
+ // window is opened instead (a slow-hydrating session must not be
839
+ // mistaken for a guest — that turned a signed-in run into a guest and
840
+ // Qwen's age gate then blocked every send).
841
+ guestAllowed: false,
817
842
  canShowWindow: canShowWindow(process.env, process.platform),
818
843
  tuiLogin: this.onLoginPrompt
819
844
  ? async () => await this._promptAndLogin()
820
845
  : undefined,
821
846
  timeoutMs: this.loginTimeoutMs,
822
847
  });
848
+ if (process.env.ZAMES_LOGIN_DEBUG)
849
+ console.error('qwen outcome=' + outcome);
823
850
  if (outcome === 'guest')
824
851
  this._notice(this._t('qwen.guest_mode'));
825
852
  await this._persistSessionIfNeeded().catch(() => { });
@@ -1680,6 +1707,10 @@ export class QwenBrowser {
1680
1707
  report(gap);
1681
1708
  this._emitSendState('paused');
1682
1709
  await this._sleepInterruptible(gap, report);
1710
+ // Back to GENERATING: nothing has been sent yet, so a "done" badge here
1711
+ // would show up before the question even left the page.
1712
+ if (!this._abort && !this._askCancelled)
1713
+ this._emitSendState('generating');
1683
1714
  }
1684
1715
  async _sleepInterruptible(ms, onTick) {
1685
1716
  const step = 100;
@@ -1711,6 +1742,12 @@ export class QwenBrowser {
1711
1742
  async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
1712
1743
  let lastErr = null;
1713
1744
  let attempt = 0;
1745
+ const backoffCfg = {
1746
+ infiniteRetries: this.infiniteRetries,
1747
+ maxRetries: Math.max(1, this.maxRateLimitRetries),
1748
+ baseWaitMs: 5000,
1749
+ maxWaitMs: this.maxRetryWaitMs,
1750
+ };
1714
1751
  let rateLimitRetries = 0;
1715
1752
  let serverBusyRetries = 0;
1716
1753
  let incompleteRetries = 0;
@@ -1721,16 +1758,16 @@ export class QwenBrowser {
1721
1758
  let fallbackAttempted = false;
1722
1759
  this._askInFlight++;
1723
1760
  try {
1724
- while (attempt < this.askRetries) {
1761
+ while (this.infiniteRetries || attempt < this.askRetries) {
1725
1762
  try {
1726
1763
  const answer = await this._askOnce(prompt, {
1727
1764
  timeout,
1728
1765
  agent,
1729
1766
  attachments,
1730
1767
  });
1731
- if (answer !== '(прервано пользователем)') {
1732
- this._emitSendState('settled');
1733
- }
1768
+ // No transient 'settled' here: the next send flips the state
1769
+ // back to 'generating' and that add/remove pair is the status-row
1770
+ // flicker (same fix as the DeepSeek provider).
1734
1771
  return answer;
1735
1772
  }
1736
1773
  catch (e) {
@@ -1773,40 +1810,43 @@ export class QwenBrowser {
1773
1810
  continue;
1774
1811
  }
1775
1812
  if (/rate limit|too frequent/i.test(msg)) {
1776
- if (rateLimitRetries >= this.maxRateLimitRetries)
1813
+ if (!this.infiniteRetries &&
1814
+ rateLimitRetries >= this.maxRateLimitRetries)
1777
1815
  break;
1778
1816
  rateLimitRetries++;
1779
1817
  this._notice(this._t('qwen.rate_limit_wait', {
1780
- min: Math.ceil(this.rateLimitWaitMs / 60000),
1818
+ min: Math.ceil(backoffWaitMs(backoffCfg, rateLimitRetries) / 60000),
1781
1819
  attempt: rateLimitRetries,
1782
- max: this.maxRateLimitRetries,
1820
+ max: this.infiniteRetries ? '∞' : this.maxRateLimitRetries,
1783
1821
  }));
1784
- if (await this._sleepInterruptible(this.rateLimitWaitMs))
1822
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, rateLimitRetries)))
1785
1823
  return '(прервано пользователем)';
1786
1824
  continue;
1787
1825
  }
1788
1826
  if (e instanceof QwenServerBusyError || /server busy/i.test(msg)) {
1789
- if (serverBusyRetries >= this.maxServerBusyRetries)
1827
+ if (!this.infiniteRetries &&
1828
+ serverBusyRetries >= this.maxServerBusyRetries)
1790
1829
  break;
1791
1830
  serverBusyRetries++;
1792
1831
  this._notice(this._t('qwen.server_busy_wait', {
1793
- sec: Math.ceil(this.serverBusyWaitMs / 1000),
1832
+ sec: Math.ceil(backoffWaitMs(backoffCfg, serverBusyRetries) / 1000),
1794
1833
  attempt: serverBusyRetries,
1795
- max: this.maxServerBusyRetries,
1834
+ max: this.infiniteRetries ? '∞' : this.maxServerBusyRetries,
1796
1835
  }));
1797
- if (await this._sleepInterruptible(this.serverBusyWaitMs))
1836
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, serverBusyRetries)))
1798
1837
  return '(прервано пользователем)';
1799
1838
  continue;
1800
1839
  }
1801
1840
  if (e instanceof QwenIncompleteError) {
1802
- if (incompleteRetries >= this.maxIncompleteRetries)
1841
+ if (!this.infiniteRetries &&
1842
+ incompleteRetries >= this.maxIncompleteRetries)
1803
1843
  break;
1804
1844
  incompleteRetries++;
1805
1845
  this._notice(theme.warn(this._t('qwen.incomplete_retry', {
1806
1846
  attempt: incompleteRetries,
1807
- max: this.maxIncompleteRetries,
1847
+ max: this.infiniteRetries ? '∞' : this.maxIncompleteRetries,
1808
1848
  })));
1809
- if (await this._sleepInterruptible(this.incompleteWaitMs))
1849
+ if (await this._sleepInterruptible(backoffWaitMs(backoffCfg, incompleteRetries)))
1810
1850
  return '(прервано пользователем)';
1811
1851
  continue;
1812
1852
  }
@@ -120,6 +120,31 @@ export function normalizeRestoredMessage(m) {
120
120
  // copies after a flaky run), so drop it like the other nudges.
121
121
  if (/^The previous turn was cut off/.test(text))
122
122
  return null;
123
+ // The OTHER loop nudges (a malformed call, a plain-text answer, "the work
124
+ // is NOT finished", "you stopped mid-task", a repeated call) are written by
125
+ // the agent to itself in the same way. A flaky run left a wall of these in
126
+ // /chats: the operator saw them as their OWN messages ("❯ Вы: Your previous
127
+ // answer was not recognized…") and thought the terminal history was wrong.
128
+ if (/^Your previous answer was not recognized as a tool call/.test(text))
129
+ return null;
130
+ if (/^Plain text is a PROTOCOL VIOLATION/.test(text))
131
+ return null;
132
+ if (/^You wrote plain text without a tool call/.test(text))
133
+ return null;
134
+ if (/^You stopped after a tool call and wrote plain text/.test(text))
135
+ return null;
136
+ if (/^You stopped mid-task without finishing/.test(text))
137
+ return null;
138
+ if (/^Last step: call the respond tool/.test(text))
139
+ return null;
140
+ if (/^You have called the SAME tool with the SAME arguments/.test(text))
141
+ return null;
142
+ if (/^It looks like you meant to call a tool but did not/.test(text))
143
+ return null;
144
+ if (/^Continue the task\. If you need a tool/.test(text))
145
+ return null;
146
+ if (/^Before finishing: you wrote\/edited a runnable code file/.test(text))
147
+ return null;
123
148
  if (/^\[system\]/.test(text))
124
149
  return null;
125
150
  if (/^The user ran \//.test(text))
@@ -0,0 +1,89 @@
1
+ // Incremental RETRY BACKOFF — how long to wait before a retry of a
2
+ // transient failure (rate limit / server busy / truncated turn / watchdog
3
+ // timeout).
4
+ //
5
+ // WHY: the old per-provider loop had a FIXED pause per family and a hard
6
+ // retry ceiling. A long block (DeepSeek answers "too frequent" for minutes)
7
+ // then failed the very first retry at the same fixed pause, burned the whole
8
+ // budget on cheap retries and surrendered while the block was still active —
9
+ // the operator's «лимит повторов исчерпан». Every retry must wait LONGER than
10
+ // the previous one (up to a ceiling), and the budget must not run out while
11
+ // the server is still asking us to wait.
12
+ //
13
+ // PURE: the caller owns the counter and the clock; this module only decides
14
+ // the next wait and whether the family may be retried again.
15
+ export const DEFAULT_BACKOFF = {
16
+ infiniteRetries: true,
17
+ maxRetries: 6,
18
+ baseWaitMs: 5 * 60_000,
19
+ // Half an hour is where a wait stops being a pause and becomes a dead run:
20
+ // the operator can restart instead of watching a spinner for an hour.
21
+ maxWaitMs: 30 * 60_000,
22
+ };
23
+ /** A wait never drops below this: a server-busy wait of 0 would retry in a
24
+ * tight loop against a server that just told us it is overloaded. */
25
+ const MIN_WAIT_MS = 500;
26
+ /** Double on every retry: 5m, 10m, 20m, 30m, 30m, 30m... */
27
+ export const BACKOFF_FACTOR = 2;
28
+ // +/-10% jitter: several agents (or several families in one agent) must not
29
+ // retry in the same second and trip the server's burst detector again.
30
+ const JITTER_RATIO = 0.1;
31
+ /** A ceiling of 0 or Infinity means "no ceiling" (a hand-edited config). */
32
+ export function normalizeMaxWait(maxWaitMs) {
33
+ return Number.isFinite(maxWaitMs) && maxWaitMs > 0 ? maxWaitMs : 0;
34
+ }
35
+ /** Clamp one wait to the configured ceiling. */
36
+ export function clampWait(waitMs, maxWaitMs) {
37
+ if (!(Number.isFinite(waitMs) && waitMs > 0))
38
+ return 0;
39
+ const cap = normalizeMaxWait(maxWaitMs);
40
+ return cap ? Math.min(waitMs, cap) : waitMs;
41
+ }
42
+ /** Shortest acceptable pause. */
43
+ export function floorWait(waitMs) {
44
+ if (!(Number.isFinite(waitMs) && waitMs > 0))
45
+ return 0;
46
+ return Math.max(MIN_WAIT_MS, waitMs);
47
+ }
48
+ /**
49
+ * PURE: the wait before the next retry, `attempt` being 1-based (1 = the
50
+ * first retry, i.e. after the first failure).
51
+ *
52
+ * `serverWaitMs` (a Retry-After) WINS over the learned curve: when the server
53
+ * names its own pause, guessing a shorter one only burns another attempt.
54
+ * It is still clamped to the ceiling, and it never goes below the curve, so
55
+ * a tiny Retry-After cannot shorten the escalation.
56
+ */
57
+ export function backoffWaitMs(cfg, attempt, opts = {}) {
58
+ const n = Math.max(1, Math.floor(attempt));
59
+ const base = Number.isFinite(cfg.baseWaitMs) && cfg.baseWaitMs > 0
60
+ ? cfg.baseWaitMs
61
+ : DEFAULT_BACKOFF.baseWaitMs;
62
+ let wait = base * Math.pow(BACKOFF_FACTOR, n - 1);
63
+ if (!Number.isFinite(wait))
64
+ wait = Number.MAX_SAFE_INTEGER;
65
+ const server = opts.serverWaitMs;
66
+ if (typeof server === 'number' && Number.isFinite(server) && server > 0) {
67
+ wait = Math.max(wait, server);
68
+ }
69
+ wait = clampWait(wait, cfg.maxWaitMs);
70
+ if (opts.jitter !== false) {
71
+ const spread = Math.round(wait * JITTER_RATIO);
72
+ if (spread > 0)
73
+ wait += Math.round((Math.random() * 2 - 1) * spread);
74
+ }
75
+ return Math.max(MIN_WAIT_MS, Math.round(wait));
76
+ }
77
+ /**
78
+ * PURE: may the family be retried once more after `attemptsMade` failures?
79
+ * With `infiniteRetries` on the answer is always yes — the escalation and
80
+ * the cancel path are what keep an endless wait honest, not a counter.
81
+ */
82
+ export function canRetry(cfg, attemptsMade) {
83
+ if (cfg.infiniteRetries === true)
84
+ return true;
85
+ const max = Math.max(0, Math.floor(cfg.maxRetries));
86
+ if (max === 0)
87
+ return true;
88
+ return attemptsMade < max;
89
+ }
@@ -0,0 +1,98 @@
1
+ // The retry POLICY of a provider's ask() as a PURE function.
2
+ //
3
+ // WHY: the three providers ran the same loop — call _askOnce, classify the
4
+ // thrown error into a family, retry that family within its own budget — and
5
+ // each copy had its own order of checks and its own wording. The loop itself
6
+ // (sends, throttling, aborts) stays in the browser class; only the DECISION is
7
+ // here, so every branch is unit-tested without a browser.
8
+ //
9
+ // Incremental retries (default): the three transient families (and the
10
+ // agent-loop watchdog) no longer count their attempts; the WAIT grows from the
11
+ // family's base (rate limit: 5m, busy: 3s, incomplete: 2s) up to the ceiling
12
+ // (browser.maxRetryWaitMs, default 30m), and the only way out is a cancel
13
+ // (Esc/Ctrl+C) or a FATAL error (bad login, captcha, guest limit) — the same
14
+ // place it was before.
15
+ //
16
+ // One thing matters for understanding this module: a retry that waits the
17
+ // SAME fixed pause forever is not endless, it is a busy loop that never lets
18
+ // the block expire. The escalation is what makes "infinite" safe.
19
+ import { backoffWaitMs, canRetry, } from './retry-backoff.js';
20
+ /**
21
+ * Decide what to do after a failed attempt.
22
+ *
23
+ * classify maps the thrown error to a family; it lives in the provider
24
+ * because the backend-specific markers (guest limit, age gate, captcha) are
25
+ * provider knowledge. A null classification falls through to the generic
26
+ * attempt budget.
27
+ *
28
+ * backoff carries the retry mode (infiniteRetries, the ceiling) — see
29
+ * src/retry-backoff.ts.
30
+ */
31
+ export function decideRetry(family, state, budgets, waits, backoff, opts = {}) {
32
+ // Strict === true: the infinite mode is opt-in by an EXPLICIT config field.
33
+ // A caller that omits it keeps the historical counted budget, so a
34
+ // partially built config can never silently retry forever.
35
+ const infinite = backoff?.infiniteRetries === true; // strict
36
+ const cfg = {
37
+ infiniteRetries: infinite,
38
+ maxRetries: backoff?.maxRetries ?? 6,
39
+ baseWaitMs: waits.rateLimitMs,
40
+ maxWaitMs: backoff?.maxWaitMs ?? 0,
41
+ };
42
+ // The cause of a transient failure decides the FIRST wait — a rate block is
43
+ // waited out for minutes, a transient busy notice for seconds — and the
44
+ // escalation adds to it from there.
45
+ const baseWait = (f) => f === 'server-busy'
46
+ ? waits.serverBusyMs
47
+ : f === 'incomplete'
48
+ ? waits.incompleteMs
49
+ : waits.rateLimitMs;
50
+ const waitFor = (f, attempts) => backoffWaitMs({ ...cfg, baseWaitMs: baseWait(f) }, attempts, {
51
+ // A server Retry-After is the best clock we have; it is still clamped
52
+ // to the ceiling and never shortens the escalation.
53
+ serverWaitMs: f === 'rate-limit' ? waits.retryAfterMs : undefined,
54
+ jitter: opts.jitter,
55
+ });
56
+ switch (family) {
57
+ case 'fatal':
58
+ return { kind: 'give-up', family };
59
+ case 'context-limit':
60
+ if (state.contextLimitRetries >= budgets.maxContextLimitRetries) {
61
+ return { kind: 'give-up', family };
62
+ }
63
+ return { kind: 'compact', family };
64
+ case 'rate-limit':
65
+ if (!canRetry(cfg, state.rateLimitRetries)) {
66
+ return { kind: 'give-up', family };
67
+ }
68
+ return {
69
+ kind: 'wait',
70
+ family,
71
+ waitMs: waitFor('rate-limit', state.rateLimitRetries + 1),
72
+ };
73
+ case 'server-busy':
74
+ if (!canRetry(cfg, state.serverBusyRetries)) {
75
+ return { kind: 'give-up', family };
76
+ }
77
+ return {
78
+ kind: 'wait',
79
+ family,
80
+ waitMs: waitFor('server-busy', state.serverBusyRetries + 1),
81
+ };
82
+ case 'incomplete':
83
+ if (!canRetry(cfg, state.incompleteRetries)) {
84
+ return { kind: 'give-up', family };
85
+ }
86
+ return {
87
+ kind: 'wait',
88
+ family,
89
+ waitMs: waitFor('incomplete', state.incompleteRetries + 1),
90
+ };
91
+ default:
92
+ // A generic failure is retried while the TOTAL attempt budget lasts.
93
+ if (state.attempt >= budgets.askRetries - 1) {
94
+ return { kind: 'give-up', family: 'generic' };
95
+ }
96
+ return { kind: 'generic', waitMs: 2000 * (state.attempt + 1) };
97
+ }
98
+ }
package/dist/run-task.js CHANGED
@@ -406,6 +406,8 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
406
406
  locale,
407
407
  askDeadlineMs: providerAskDeadlineMs(config),
408
408
  maxAfterToolRetries: config.browser.maxAfterToolRetries,
409
+ infiniteRetries: config.browser.infiniteRetries,
410
+ maxRetryWaitMs: config.browser.maxRetryWaitMs,
409
411
  // Auto-compact between tool calls when the context nears the window
410
412
  // limit. The callback is provided by the caller so it can refresh the
411
413
  // outer currentChatId; it serializes with the throttle because the
package/dist/subagent.js CHANGED
@@ -65,7 +65,7 @@ function subagentPersona(type) {
65
65
  * returns only the report.
66
66
  */
67
67
  export function createSubagentRunner(opts) {
68
- const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, onAskPermission, } = opts;
68
+ const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, infiniteRetries, maxRetryWaitMs, onAskPermission, } = opts;
69
69
  let used = 0;
70
70
  return async (req) => {
71
71
  if (used >= maxSubagents) {
@@ -133,6 +133,8 @@ export function createSubagentRunner(opts) {
133
133
  locale,
134
134
  askDeadlineMs,
135
135
  maxAfterToolRetries,
136
+ infiniteRetries,
137
+ maxRetryWaitMs,
136
138
  // A subagent NEVER touches BACKLOG.md, even in the operator's dev
137
139
  // mode: its sub-task is scoped, and a general subagent appending
138
140
  // "improvement" notes would be unrelated, unsupervised edits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.90.18",
3
+ "version": "2.92.0",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen or GLM — 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": {