zames_pro 2.73.0 → 2.73.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.73.1] - 2026-10-11
11
+
12
+ ### Fixed
13
+
14
+ - **qwen:** live provider switch, verified send, server-busy handling
15
+
10
16
  ## [2.73.0] - 2026-10-11
11
17
 
12
18
  ### Added
@@ -547,9 +547,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
547
547
  (justRanTool
548
548
  ? 'You stopped after a tool call and wrote plain text. '
549
549
  : 'You wrote plain text without a tool call. ') +
550
+ 'Plain text is a PROTOCOL VIOLATION — the operator never sees it. ' +
550
551
  'The task is not finished. Reply with EXACTLY one JSON tool-call ' +
551
552
  'object, no text before or after, for example: ' +
552
553
  '{\"tool\": \"Bash\", \"args\": {\"command\": \"...\"}}. ' +
554
+ 'Do NOT explain, plan or narrate — call a tool. ' +
553
555
  'If the task is really done — call respond with the final message.';
554
556
  continue;
555
557
  }
@@ -1,8 +1,8 @@
1
1
  // Common UI strings: app/common, status, tasks, messages, diff, context, undo, cd.
2
2
  export const commonMessages = {
3
3
  'app.tagline': {
4
- ru: 'агент поверх chat.deepseek.com через Playwright',
5
- en: 'coding agent over chat.deepseek.com via Playwright',
4
+ ru: 'агент поверх chat.deepseek.com / chat.qwen.ai через Playwright',
5
+ en: 'coding agent over chat.deepseek.com / chat.qwen.ai via Playwright',
6
6
  },
7
7
  'common.yes': { ru: 'да', en: 'yes' },
8
8
  'common.no': { ru: 'нет', en: 'no' },
package/dist/i18n/qwen.js CHANGED
@@ -26,10 +26,6 @@ export const qwenMessages = {
26
26
  ru: 'Qwen: войти не удалось. Войдите вручную в окне браузера (запустите с --headed) и повторите.',
27
27
  en: 'Qwen: sign-in failed. Sign in manually in the browser window (launch with --headed) and retry.',
28
28
  },
29
- 'qwen.attach_unsupported': {
30
- ru: 'Qwen: вложения пока не поддерживаются — файлы не отправлены.',
31
- en: 'Qwen: attachments are not supported yet — files were not sent.',
32
- },
33
29
  'qwen.guest_limit': {
34
30
  ru: 'Qwen: исчерпан суточный лимит гостя. Войдите в аккаунт (email / Google / GitHub) командой /provider login или запустите с --headed.',
35
31
  en: 'Qwen: the guest daily limit is exhausted. Sign in (email / Google / GitHub) with /provider login, or launch with --headed.',
@@ -55,16 +51,24 @@ export const qwenMessages = {
55
51
  en: 'Failed to fetch the Qwen model list: {v}',
56
52
  },
57
53
  'provider.switched': {
58
- ru: 'Провайдер чата: {v}. Перезапустите zames, чтобы сменить браузер.',
59
- en: 'Chat provider: {v}. Restart zames to switch the browser.',
54
+ ru: 'Провайдер чата: {v}.',
55
+ en: 'Chat provider: {v}.',
56
+ },
57
+ 'provider.switching': {
58
+ ru: 'Переключаю провайдера на {v}: закрываю старый браузер и открываю {v}...',
59
+ en: 'Switching provider to {v}: closing the old browser and opening {v}...',
60
+ },
61
+ 'provider.switch_failed': {
62
+ ru: '✖ Не удалось открыть браузер {v}: {error}. Остаюсь на прежнем провайдере; проверь вход и попробуй снова.',
63
+ en: '✖ Could not open the {v} browser: {error}. Staying on the previous provider; check the login and retry.',
60
64
  },
61
65
  'provider.usage': {
62
66
  ru: 'Использование: /provider <deepseek|qwen> | /provider login | /provider status',
63
67
  en: 'Usage: /provider <deepseek|qwen> | /provider login | /provider status',
64
68
  },
65
69
  'provider.handoff_saved': {
66
- ru: 'Контекст текущего чата сжат и сохранён для {v}. Он подхватится при следующем запуске.',
67
- en: 'The current chat was summarized and saved for {v}. It will be carried over on the next launch.',
70
+ ru: 'Контекст текущего чата сжат и сохранён для {v}. Он подхватится в следующей задаче.',
71
+ en: 'The current chat was summarized and saved for {v}. It will be carried over with the next task.',
68
72
  },
69
73
  'provider.handoff_used': {
70
74
  ru: '📎 Подхватил контекст предыдущего провайдера (carried-over summary).',
@@ -82,4 +86,47 @@ export const qwenMessages = {
82
86
  ru: 'Провайдер: {v}, модель: {m}, вход: {auth}',
83
87
  en: 'Provider: {v}, model: {m}, login: {auth}',
84
88
  },
89
+ // Qwen-specific statuses. The DeepSeek path had these under `ds.*`; using
90
+ // the SAME keys from QwenBrowser broke provider encapsulation (a Qwen notice
91
+ // was labelled with DeepSeek wording), so they live here instead.
92
+ 'qwen.rate_limit_wait': {
93
+ ru: '⏳ {provider}: «слишком часто». Жду {min} мин ({attempt}/{max}) и повторю отправку...',
94
+ en: '⏳ {provider}: "too frequent". Waiting {min} min ({attempt}/{max}), then resending...',
95
+ },
96
+ 'qwen.server_busy_wait': {
97
+ ru: '⏳ {provider}: сервер занят. Жду {sec}с ({attempt}/{max}) и повторю...',
98
+ en: '⏳ {provider}: server busy. Waiting {sec}s ({attempt}/{max}), then retrying...',
99
+ },
100
+ 'qwen.incomplete_retry': {
101
+ ru: '⏳ {provider} оборвал ответ — переотправляю задачу ({attempt}/{max})...',
102
+ en: '⏳ {provider} truncated the answer — resending the task ({attempt}/{max})...',
103
+ },
104
+ 'qwen.ask_retry': {
105
+ ru: '⚠ Отправка не удалась (попытка {attempt}/{max} на этот запрос): {error} — повторяю...',
106
+ en: '⚠ Send failed (attempt {attempt}/{max} for this request): {error} — retrying...',
107
+ },
108
+ 'qwen.ask_failed': {
109
+ ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
110
+ en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
111
+ },
112
+ 'qwen.attach_read_failed': {
113
+ ru: '⚠ не удалось прочитать вложение {name}: {error}',
114
+ en: '⚠ could not read the attachment {name}: {error}',
115
+ },
116
+ 'qwen.attach_no_input': {
117
+ ru: '⚠ не найдено поле загрузки файлов — вложения не прикреплены',
118
+ en: '⚠ file upload field not found — the attachments were not attached',
119
+ },
120
+ 'qwen.input_missing': {
121
+ ru: '✖ Не найдено поле ввода на странице {provider}. Запусти /debug-dom и поправь INPUT_SELECTORS.',
122
+ en: '✖ The {provider} input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
123
+ },
124
+ 'qwen.send_no_new_answer': {
125
+ ru: '✖ Новый ответ не получен — на странице остался прежний текст. Возможно, сообщение не отправилось (кнопка отправки не нажалась). Проверь чат {provider} вручную.',
126
+ en: '✖ No new answer received — the page still shows the previous text. The message may not have been sent (the send button was not pressed). Check the {provider} chat manually.',
127
+ },
128
+ 'qwen.send_button_missing': {
129
+ ru: '✖ Кнопка отправки Qwen не найдена или недоступна — сообщение не отправлено. Проверь чат вручную.',
130
+ en: '✖ The Qwen send button was not found or is disabled — the message was not sent. Check the chat manually.',
131
+ },
85
132
  };
package/dist/index.js CHANGED
@@ -613,6 +613,46 @@ function providerAnswerTimeoutMs() {
613
613
  function browserProviderUrl() {
614
614
  return providerChatUrlPrefix(config.provider);
615
615
  }
616
+ /**
617
+ * Build the browser for a provider. Called at startup AND on a live
618
+ * `/provider` switch, so both paths construct the class the same way. The
619
+ * two backends differ in every layer (selectors, SSE format, chat-id scheme),
620
+ * so the choice is a class, not a flag; the shared timings live in
621
+ * config.browser and only Qwen-only knobs are overridden.
622
+ */
623
+ function createProviderBrowser(provider) {
624
+ const persistAuth = (username, password) => {
625
+ config.browser.auth.username = username;
626
+ config.browser.auth.password = password;
627
+ try {
628
+ writeConfigValue('home', 'browser.auth.username', username);
629
+ writeConfigValue('home', 'browser.auth.password', password);
630
+ }
631
+ catch { }
632
+ };
633
+ return provider === 'qwen'
634
+ ? new QwenBrowser({
635
+ headless,
636
+ debug,
637
+ channel: config.browserChannel,
638
+ ...config.browser,
639
+ // Qwen-specific overrides (see config.qwen): no 15s DeepSeek
640
+ // throttle, a longer cold-start answer timeout.
641
+ minSendIntervalMs: config.qwen.minSendIntervalMs,
642
+ answerTimeoutMs: config.qwen.answerTimeoutMs,
643
+ model: config.qwen.model,
644
+ locale: currentLocale,
645
+ onAuthSave: persistAuth,
646
+ })
647
+ : new DeepSeekBrowser({
648
+ headless,
649
+ debug,
650
+ channel: config.browserChannel,
651
+ ...config.browser,
652
+ locale: currentLocale,
653
+ onAuthSave: persistAuth,
654
+ });
655
+ }
616
656
  /**
617
657
  * Print the dialogue of the just-opened chat into the terminal. Called by
618
658
  * /resume, /resume-id and on startup when a previous session is restored, so
@@ -784,47 +824,10 @@ async function main() {
784
824
  // class choice, not a flag. The shared timings live in config.browser; only
785
825
  // the model is provider-specific. A provider switch takes effect on the
786
826
  // next launch (the operator is told to restart).
787
- const provider = asProviderId(config.provider);
788
- const browser = provider === 'qwen'
789
- ? new QwenBrowser({
790
- headless,
791
- debug,
792
- channel: config.browserChannel,
793
- ...config.browser,
794
- // Qwen-specific overrides (see config.qwen): no 15s DeepSeek
795
- // throttle, a longer cold-start answer timeout.
796
- minSendIntervalMs: config.qwen.minSendIntervalMs,
797
- answerTimeoutMs: config.qwen.answerTimeoutMs,
798
- model: config.qwen.model,
799
- locale: currentLocale,
800
- onAuthSave: (username, password) => {
801
- config.browser.auth.username = username;
802
- config.browser.auth.password = password;
803
- try {
804
- writeConfigValue('home', 'browser.auth.username', username);
805
- writeConfigValue('home', 'browser.auth.password', password);
806
- }
807
- catch { }
808
- },
809
- })
810
- : new DeepSeekBrowser({
811
- headless,
812
- debug,
813
- channel: config.browserChannel,
814
- ...config.browser,
815
- locale: currentLocale,
816
- // Persist credentials the operator typed in the terminal so the next
817
- // launch can re-login silently after a logout.
818
- onAuthSave: (username, password) => {
819
- config.browser.auth.username = username;
820
- config.browser.auth.password = password;
821
- try {
822
- writeConfigValue('home', 'browser.auth.username', username);
823
- writeConfigValue('home', 'browser.auth.password', password);
824
- }
825
- catch { }
826
- },
827
- });
827
+ // `let`, not `const`: a live /provider switch disposes this instance and
828
+ // builds the other one IN PLACE (see the /provider block in the main loop),
829
+ // so the URL changes without restarting zames.
830
+ let browser = createProviderBrowser(asProviderId(config.provider));
828
831
  const bootSpinner = mod.createSpinner(currentLocale);
829
832
  bootSpinner.thinking();
830
833
  try {
@@ -2062,69 +2065,135 @@ async function main() {
2062
2065
  console.log(theme.system(t('provider.switched', { v: arg })));
2063
2066
  continue;
2064
2067
  }
2065
- // CARRY-OVER: a chat id only exists on its own backend, so switching
2066
- // providers loses the conversation. Summarize the CURRENT chat (still
2067
- // on the OLD browser object) and persist a handoff for the target; on
2068
- // the next launch the new provider posts it as the first message.
2069
- if (editor)
2070
- editor.lock(t('msg.input_locked'));
2071
- try {
2072
- const spin = editor || mod.createSpinner(currentLocale);
2073
- spin.thinking();
2074
- let summary = '';
2075
- try {
2076
- const answer = await browser.ask(mod.commands.buildCompactPrompt(currentLocale), {
2077
- agent: true,
2078
- timeout: providerAnswerTimeoutMs(),
2079
- });
2080
- const trimmed = String(answer || '').trim();
2081
- if (mod.commands.isUsableCompactSummary(trimmed))
2082
- summary = trimmed;
2068
+ // Is there anything worth carrying over? A FRESH/EMPTY chat has no
2069
+ // context to summarize, so switching it is a pure URL/backend change:
2070
+ // summarizing an empty chat used to post a useless "please compact"
2071
+ // request into a brand-new chat (a real complaint). We only build a
2072
+ // handoff when the chat actually has messages.
2073
+ let chatHasMessages = false;
2074
+ if (browser.fetchChatMessages) {
2075
+ const cid = await browser.getCurrentChatId().catch(() => null);
2076
+ if (cid) {
2077
+ const all = await browser.fetchChatMessages(cid).catch(() => []);
2078
+ chatHasMessages =
2079
+ mod.commands.trimRestoredMessages(all, 0).length > 0;
2083
2080
  }
2084
- catch (e) {
2085
- if (debug)
2086
- console.error('handoff summary failed:', e.message);
2087
- }
2088
- // Fall back to the LOCAL history when the model summary is unusable.
2089
- if (!summary && browser.fetchChatMessages) {
2090
- const cid = await browser.getCurrentChatId().catch(() => null);
2091
- const all = cid
2092
- ? await browser.fetchChatMessages(cid).catch(() => [])
2093
- : [];
2094
- const msgs = mod.commands.trimRestoredMessages(all, 0);
2095
- if (msgs.length) {
2096
- summary = mod.commands.formatRestoredHistory(msgs, { limit: 40 });
2081
+ }
2082
+ if (chatHasMessages) {
2083
+ // CARRY-OVER: a chat id only exists on its own backend, so switching
2084
+ // providers loses the conversation. Summarize the CURRENT chat (still
2085
+ // on the OLD browser object) and persist a handoff for the target; it
2086
+ // is posted as the first message once the new provider is live.
2087
+ if (editor)
2088
+ editor.lock(t('msg.input_locked'));
2089
+ try {
2090
+ const spin = editor || mod.createSpinner(currentLocale);
2091
+ spin.thinking();
2092
+ let summary = '';
2093
+ try {
2094
+ const answer = await browser.ask(mod.commands.buildCompactPrompt(currentLocale), {
2095
+ agent: true,
2096
+ timeout: providerAnswerTimeoutMs(),
2097
+ });
2098
+ const trimmed = String(answer || '').trim();
2099
+ if (mod.commands.isUsableCompactSummary(trimmed))
2100
+ summary = trimmed;
2101
+ }
2102
+ catch (e) {
2103
+ if (debug)
2104
+ console.error('handoff summary failed:', e.message);
2105
+ }
2106
+ // Fall back to the LOCAL history when the model summary is unusable.
2107
+ if (!summary && browser.fetchChatMessages) {
2108
+ const cid = await browser.getCurrentChatId().catch(() => null);
2109
+ const all = cid
2110
+ ? await browser.fetchChatMessages(cid).catch(() => [])
2111
+ : [];
2112
+ const msgs = mod.commands.trimRestoredMessages(all, 0);
2113
+ if (msgs.length) {
2114
+ summary = mod.commands.formatRestoredHistory(msgs, {
2115
+ limit: 40,
2116
+ });
2117
+ }
2118
+ }
2119
+ spin.stop();
2120
+ if (summary) {
2121
+ saveProviderHandoff({
2122
+ provider: arg,
2123
+ workdir: currentWorkdir,
2124
+ summary,
2125
+ task: lastTaskText || undefined,
2126
+ createdAt: new Date().toISOString(),
2127
+ });
2128
+ console.log(theme.system(t('provider.handoff_saved', { v: arg })));
2129
+ }
2130
+ else {
2131
+ console.log(theme.warn(t('provider.handoff_empty')));
2097
2132
  }
2098
2133
  }
2099
- spin.stop();
2100
- if (summary) {
2101
- saveProviderHandoff({
2102
- provider: arg,
2103
- workdir: currentWorkdir,
2104
- summary,
2105
- task: lastTaskText || undefined,
2106
- createdAt: new Date().toISOString(),
2107
- });
2108
- console.log(theme.system(t('provider.handoff_saved', { v: arg })));
2109
- }
2110
- else {
2111
- console.log(theme.warn(t('provider.handoff_empty')));
2134
+ finally {
2135
+ if (editor)
2136
+ editor.unlock();
2112
2137
  }
2113
2138
  }
2114
- finally {
2115
- if (editor)
2116
- editor.unlock();
2117
- }
2118
2139
  // Tag the current session with the OLD provider before switching.
2119
2140
  if (currentChatId)
2120
2141
  saveLastChat(currentChatId, currentWorkdir);
2142
+ // Persist the choice FIRST so a crash mid-switch still remembers it.
2121
2143
  config.provider = arg;
2122
2144
  config.qwen.enabled = arg === 'qwen';
2123
2145
  try {
2124
2146
  writeConfigValue('home', 'provider', arg);
2125
2147
  }
2126
2148
  catch { }
2127
- console.log(theme.system(t('provider.switched', { v: arg })));
2149
+ // LIVE SWITCH: dispose the old backend and open the new one in place,
2150
+ // so the operator does not have to restart zames. The chat is NOT
2151
+ // resumable across providers (different chat ids), so the new backend
2152
+ // starts a FRESH chat and the handoff (if any) is posted with the next
2153
+ // task.
2154
+ if (editor)
2155
+ editor.lock(t('msg.input_locked'));
2156
+ try {
2157
+ console.log(theme.system(t('provider.switching', { v: arg })));
2158
+ // Launch the NEW backend FIRST; only close the old one once it is up.
2159
+ // If we closed the old browser before the new one launched and the
2160
+ // launch failed, `browser` would point at a dead instance and every
2161
+ // later command would break.
2162
+ const nextBrowser = createProviderBrowser(arg);
2163
+ await nextBrowser.launch();
2164
+ await nextBrowser.waitForLogin();
2165
+ const oldBrowser = browser;
2166
+ browser = nextBrowser;
2167
+ await oldBrowser.close().catch(() => { });
2168
+ // The new backend has no chat yet.
2169
+ currentChatId = await browser.getCurrentChatId().catch(() => null);
2170
+ if (editor) {
2171
+ editor.setProvider(providerLabel(arg));
2172
+ }
2173
+ try {
2174
+ warnUiHealth(await browser.probeUiHealth());
2175
+ }
2176
+ catch { }
2177
+ console.log(theme.system(t('provider.switched', { v: arg })));
2178
+ }
2179
+ catch (e) {
2180
+ // The new backend did not come up: keep the OLD one active so the
2181
+ // session keeps working, and roll the persisted choice back.
2182
+ config.provider = from;
2183
+ config.qwen.enabled = from === 'qwen';
2184
+ try {
2185
+ writeConfigValue('home', 'provider', from);
2186
+ }
2187
+ catch { }
2188
+ console.error(theme.warn(t('provider.switch_failed', {
2189
+ v: arg,
2190
+ error: e.message,
2191
+ })));
2192
+ }
2193
+ finally {
2194
+ if (editor)
2195
+ editor.unlock();
2196
+ }
2128
2197
  continue;
2129
2198
  }
2130
2199
  console.error(theme.warn(t('provider.usage')));
package/dist/providers.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // 1. add an entry here (id, label, chat URL prefix, extra config section);
4
4
  // 2. add the provider's `<name>-ui.ts` + `<name>-net-capture.ts` +
5
5
  // `<name>-browser.ts` (implementing ChatProvider, see types.ts);
6
- // 3. register the class in `createBrowser()` in index.ts.
6
+ // 3. register the class in `createProviderBrowser()` in index.ts.
7
7
  // Everything else (config enum, /provider validation, sessions tagging, the
8
8
  // /doctor row, the {provider} label in operator messages, /chat URL) reads
9
9
  // from HERE, so nothing else has to be touched.
@@ -27,7 +27,7 @@ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadle
27
27
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
28
28
  import { probeUiHealth, } from './ui-health.js';
29
29
  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, 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';
30
- import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, } from './qwen-net-capture.js';
30
+ import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
31
31
  // A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
32
32
  // contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
33
33
  // Qwen's cookies apart also means switching providers never logs the other
@@ -56,6 +56,8 @@ export class QwenBrowser {
56
56
  serverBusyWaitMs;
57
57
  maxIncompleteRetries;
58
58
  incompleteWaitMs;
59
+ startWaitMs;
60
+ sendButtonWaitMs;
59
61
  // The Qwen model id sent with each completion request. Empty means "let the
60
62
  // site pick" (its own default). Read from config, overridable with /model.
61
63
  model;
@@ -82,6 +84,11 @@ export class QwenBrowser {
82
84
  // limit for today's usage"). Distinct from _netRateLimited: it cannot be
83
85
  // waited out, so ask() fails fast with a "log in" message.
84
86
  _netGuestLimit;
87
+ // True when the completion body carries the server-busy / service-unavailable
88
+ // notice. Mirrors _netRateLimited: the DOM toast is short-lived, so the RAW
89
+ // body is the reliable signal. ask() then backs off instead of waiting out
90
+ // the whole answer deadline.
91
+ _netServerBusy;
85
92
  _netChatId;
86
93
  _lastTokenUsage;
87
94
  _toggles;
@@ -101,7 +108,7 @@ export class QwenBrowser {
101
108
  onSendState;
102
109
  onNotice;
103
110
  userAgent;
104
- 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, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
111
+ 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, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
105
112
  this.headless = headless;
106
113
  this.debug = debug;
107
114
  this.channel = channel;
@@ -116,6 +123,8 @@ export class QwenBrowser {
116
123
  this.serverBusyWaitMs = serverBusyWaitMs;
117
124
  this.maxIncompleteRetries = maxIncompleteRetries;
118
125
  this.incompleteWaitMs = incompleteWaitMs;
126
+ this.startWaitMs = startWaitMs;
127
+ this.sendButtonWaitMs = sendButtonWaitMs;
119
128
  this.model = model;
120
129
  this.deepThinking = deepThinking;
121
130
  this.webSearch = webSearch;
@@ -138,6 +147,7 @@ export class QwenBrowser {
138
147
  this._netNoAnswer = false;
139
148
  this._netRateLimited = false;
140
149
  this._netGuestLimit = false;
150
+ this._netServerBusy = false;
141
151
  this._netChatId = null;
142
152
  this._lastTokenUsage = null;
143
153
  this._toggles = { deepThinking, webSearch };
@@ -380,6 +390,12 @@ export class QwenBrowser {
380
390
  this._netRateLimited = true;
381
391
  this._netCapture = '';
382
392
  }
393
+ // The server-busy notice can arrive with a non-empty (partial) answer
394
+ // too, so it is checked OUTSIDE the `!extracted` branch as well — the
395
+ // point is to back off, not to discard a usable answer.
396
+ if (isServerBusyText(body)) {
397
+ this._netServerBusy = true;
398
+ }
383
399
  this._netCaptureAt = Date.now();
384
400
  }
385
401
  }
@@ -830,6 +846,10 @@ export class QwenBrowser {
830
846
  }, { url: MODELS_URL, auth: this._apiAuth });
831
847
  if (res?.error) {
832
848
  this._lastHistoryError = res.error;
849
+ // Surface the failure: an empty list otherwise looks identical to
850
+ // "the catalogue is empty", so /model would print "(none)" with no
851
+ // hint that the API call failed.
852
+ this._notice(this._t('qwen.model_error', { v: res.error }));
833
853
  return [];
834
854
  }
835
855
  const data = res?.data || {};
@@ -861,6 +881,7 @@ export class QwenBrowser {
861
881
  }
862
882
  catch (e) {
863
883
  this._lastHistoryError = e.message;
884
+ this._notice(this._t('qwen.model_error', { v: e.message }));
864
885
  return [];
865
886
  }
866
887
  }
@@ -1038,6 +1059,13 @@ export class QwenBrowser {
1038
1059
  // Pick Auto/Thinking/Fast in the `.qwen-thinking-selector` dropdown. The
1039
1060
  // option labels are localized, so they are matched by a loose regex.
1040
1061
  async _applyThinkingMode() {
1062
+ // Only touch the mode dropdown when the desired state changed since the
1063
+ // last apply (same rationale as _applyWebSearch): opening it on every send
1064
+ // is slow and can steal focus from the send button. A manual flip by the
1065
+ // operator is still honored because we re-read the current label below.
1066
+ const wantMode = this.deepThinking ? 1 : 0;
1067
+ if (this._appliedThinking === wantMode)
1068
+ return;
1041
1069
  try {
1042
1070
  const trigger = this.page.locator(THINKING_SELECTOR).first();
1043
1071
  if ((await trigger.count().catch(() => 0)) === 0)
@@ -1299,7 +1327,7 @@ export class QwenBrowser {
1299
1327
  if (rateLimitRetries >= this.maxRateLimitRetries)
1300
1328
  break;
1301
1329
  rateLimitRetries++;
1302
- this._notice(this._t('ds.rate_limit_wait', {
1330
+ this._notice(this._t('qwen.rate_limit_wait', {
1303
1331
  min: Math.ceil(this.rateLimitWaitMs / 60000),
1304
1332
  attempt: rateLimitRetries,
1305
1333
  max: this.maxRateLimitRetries,
@@ -1307,10 +1335,15 @@ export class QwenBrowser {
1307
1335
  await this._sleepInterruptible(this.rateLimitWaitMs);
1308
1336
  continue;
1309
1337
  }
1310
- if (/server busy/i.test(msg)) {
1338
+ if (e instanceof QwenServerBusyError || /server busy/i.test(msg)) {
1311
1339
  if (serverBusyRetries >= this.maxServerBusyRetries)
1312
1340
  break;
1313
1341
  serverBusyRetries++;
1342
+ this._notice(this._t('qwen.server_busy_wait', {
1343
+ sec: Math.ceil(this.serverBusyWaitMs / 1000),
1344
+ attempt: serverBusyRetries,
1345
+ max: this.maxServerBusyRetries,
1346
+ }));
1314
1347
  await this._sleepInterruptible(this.serverBusyWaitMs);
1315
1348
  continue;
1316
1349
  }
@@ -1318,7 +1351,7 @@ export class QwenBrowser {
1318
1351
  if (incompleteRetries >= this.maxIncompleteRetries)
1319
1352
  break;
1320
1353
  incompleteRetries++;
1321
- this._notice(theme.warn(this._t('ds.incomplete_retry', {
1354
+ this._notice(theme.warn(this._t('qwen.incomplete_retry', {
1322
1355
  attempt: incompleteRetries,
1323
1356
  max: this.maxIncompleteRetries,
1324
1357
  })));
@@ -1342,7 +1375,7 @@ export class QwenBrowser {
1342
1375
  })));
1343
1376
  }
1344
1377
  }
1345
- this._notice(theme.warn(this._t('ds.ask_retry', {
1378
+ this._notice(theme.warn(this._t('qwen.ask_retry', {
1346
1379
  attempt: attempt + 1,
1347
1380
  max: this.askRetries,
1348
1381
  error: msg,
@@ -1353,7 +1386,7 @@ export class QwenBrowser {
1353
1386
  attempt++;
1354
1387
  }
1355
1388
  }
1356
- throw new Error(this._t('ds.ask_failed', {
1389
+ throw new Error(this._t('qwen.ask_failed', {
1357
1390
  max: this.askRetries,
1358
1391
  error: lastErr?.message || '',
1359
1392
  }));
@@ -1376,7 +1409,7 @@ export class QwenBrowser {
1376
1409
  }
1377
1410
  catch (e) {
1378
1411
  failed.push({ path: f.path, name: f.name });
1379
- this._notice(theme.warn(this._t('ds.attach_read_failed', {
1412
+ this._notice(theme.warn(this._t('qwen.attach_read_failed', {
1380
1413
  name: f.name,
1381
1414
  error: e.message,
1382
1415
  })));
@@ -1421,7 +1454,7 @@ export class QwenBrowser {
1421
1454
  }
1422
1455
  catch { }
1423
1456
  }
1424
- this._notice(this._t('ds.attach_no_input'));
1457
+ this._notice(this._t('qwen.attach_no_input'));
1425
1458
  for (const f of files)
1426
1459
  failed.push({ path: f.path, name: f.name });
1427
1460
  return failed;
@@ -1498,7 +1531,7 @@ export class QwenBrowser {
1498
1531
  catch { }
1499
1532
  }
1500
1533
  if (!input)
1501
- throw new Error(this._t('ds.input_missing'));
1534
+ throw new Error(this._t('qwen.input_missing'));
1502
1535
  const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
1503
1536
  await this._waitForSendSlot(agent);
1504
1537
  if (this._abort)
@@ -1516,6 +1549,7 @@ export class QwenBrowser {
1516
1549
  this._netNoAnswer = false;
1517
1550
  this._netRateLimited = false;
1518
1551
  this._netGuestLimit = false;
1552
+ this._netServerBusy = false;
1519
1553
  this._netBody = '';
1520
1554
  await this._applyToggles();
1521
1555
  if (attachments.length) {
@@ -1523,13 +1557,26 @@ export class QwenBrowser {
1523
1557
  prompt = rewriteFailedAttachments(prompt, failed);
1524
1558
  }
1525
1559
  await this._setInputText(input, prompt);
1526
- // Send: wait for an ENABLED send button (it is disabled while the input is
1527
- // empty or an upload is in flight), then click; fall back to Enter. With
1528
- // attachments the upload can keep the button disabled for a while, so the
1529
- // window is longer in that case.
1530
- const sendDeadline = Date.now() + (attachments.length ? 30_000 : 8000);
1560
+ // Send. The input may hold the text WITHOUT the message having been sent
1561
+ // (a real complaint: "text is in the Qwen chat but the send button was not
1562
+ // pressed"). So we do not merely click once — we click AND verify, retrying
1563
+ // through a deadline until the input actually clears (Qwen clears the
1564
+ // textarea the moment it accepts the message). Verification is the ONLY
1565
+ // reliable signal: a disabled button, a menu that stole focus, or a click
1566
+ // that landed on an overlay all leave the text in place.
1567
+ //
1568
+ // We try, in order each round: (1) a click on a VISIBLE, ENABLED send
1569
+ // button, (2) Enter as a fallback. With attachments the upload can keep the
1570
+ // button disabled for a while, so the window is longer in that case.
1571
+ const sendDeadline = Date.now() + (attachments.length ? 30_000 : this.sendButtonWaitMs);
1572
+ const preSendSignal = await this._chatSignal();
1531
1573
  let sent = false;
1532
1574
  while (!sent && Date.now() < sendDeadline) {
1575
+ // (1) the send button, if present/enabled/visible. We remember whether a
1576
+ // click was actually issued: pressing Enter in the SAME round could send
1577
+ // the message a SECOND time if the click already worked but the input
1578
+ // had not cleared yet.
1579
+ let clicked = false;
1533
1580
  for (const sel of SEND_SELECTORS) {
1534
1581
  const btn = this.page.locator(sel).last();
1535
1582
  try {
@@ -1543,32 +1590,48 @@ export class QwenBrowser {
1543
1590
  if (!enabled)
1544
1591
  continue;
1545
1592
  await btn.click({ timeout: 1500 });
1546
- sent = true;
1593
+ clicked = true;
1547
1594
  break;
1548
1595
  }
1549
1596
  catch { }
1550
1597
  }
1551
- if (!sent)
1552
- await this.page.waitForTimeout(150);
1553
- }
1554
- if (!sent)
1555
- await this.page.keyboard.press('Enter');
1556
- this._lastSentAt = Date.now();
1557
- // Verify the send really went out: Qwen clears the textarea when the
1558
- // message is accepted. If the text is still there, press Enter once more.
1559
- try {
1598
+ // (2) Enter fallback — ONLY when no send button was clicked this round
1599
+ // (a build that sends solely on Enter, or a missing button). This is
1600
+ // what avoids a double send.
1601
+ if (!clicked) {
1602
+ await this.page.keyboard.press('Enter').catch(() => { });
1603
+ }
1604
+ // VERIFY: the input must clear. Give the app a moment to react. A send
1605
+ // ALSO counts as successful when the page started generating (the chat
1606
+ // signal grew / a Stop button appeared) even if the textarea did not
1607
+ // clear immediately — some builds keep the last message in the box.
1560
1608
  await this.page.waitForTimeout(300);
1561
1609
  const stillThere = await input
1562
1610
  .evaluate(readInputTrimmedTextInDom)
1563
1611
  .catch(() => '');
1564
- if (stillThere) {
1565
- await this.page.keyboard.press('Enter');
1566
- this._lastSentAt = Date.now();
1612
+ if (!stillThere) {
1613
+ sent = true;
1614
+ break;
1567
1615
  }
1616
+ const sig = await this._chatSignal().catch(() => preSendSignal);
1617
+ const stopVisible = await this._stopButtonVisible().catch(() => false);
1618
+ if (stopVisible ||
1619
+ sig.nodes > preSendSignal.nodes ||
1620
+ sig.lastLen > preSendSignal.lastLen) {
1621
+ sent = true;
1622
+ break;
1623
+ }
1624
+ await this.page.waitForTimeout(150);
1625
+ }
1626
+ this._lastSentAt = Date.now();
1627
+ if (!sent) {
1628
+ // The text is still in the input after the whole deadline: the message
1629
+ // was NOT sent. Throw so ask() retries the send instead of waiting out
1630
+ // the full answer timeout for a turn that never started.
1631
+ throw new Error(this._t('qwen.send_button_missing'));
1568
1632
  }
1569
- catch { }
1570
1633
  // Wait for the answer to start, then for it to settle.
1571
- const startDeadline = Date.now() + 30_000;
1634
+ const startDeadline = Date.now() + this.startWaitMs;
1572
1635
  const startSignal = await this._chatSignal();
1573
1636
  let started = false;
1574
1637
  while (Date.now() < startDeadline) {
@@ -1584,6 +1647,9 @@ export class QwenBrowser {
1584
1647
  if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1585
1648
  throw new QwenRateLimitError(this._netBody.slice(0, 300));
1586
1649
  }
1650
+ if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
1651
+ throw new QwenServerBusyError(this._netBody.slice(0, 300));
1652
+ }
1587
1653
  const sig = await this._chatSignal();
1588
1654
  if (sig.nodes > startSignal.nodes || sig.lastLen > startSignal.lastLen) {
1589
1655
  started = true;
@@ -1615,6 +1681,9 @@ export class QwenBrowser {
1615
1681
  if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
1616
1682
  throw new QwenRateLimitError(this._netBody.slice(0, 300));
1617
1683
  }
1684
+ if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
1685
+ throw new QwenServerBusyError(this._netBody.slice(0, 300));
1686
+ }
1618
1687
  if (this._netNoAnswer && this._netCaptureAt >= this._lastSentAt) {
1619
1688
  throw new QwenIncompleteError('finished without answer');
1620
1689
  }
@@ -1642,7 +1711,7 @@ export class QwenBrowser {
1642
1711
  const answer = this._netCapture || last;
1643
1712
  if (answer && answer !== beforeText)
1644
1713
  return answer;
1645
- throw new Error(this._t('ds.send_no_new_answer'));
1714
+ throw new Error(this._t('qwen.send_no_new_answer'));
1646
1715
  }
1647
1716
  }
1648
1717
  // Qwen-specific error classes so ask() can branch on the retryable families.
@@ -1666,3 +1735,9 @@ class QwenIncompleteError extends Error {
1666
1735
  this.name = 'QwenIncompleteError';
1667
1736
  }
1668
1737
  }
1738
+ class QwenServerBusyError extends Error {
1739
+ constructor(detail) {
1740
+ super('server busy: ' + detail);
1741
+ this.name = 'QwenServerBusyError';
1742
+ }
1743
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.73.0",
3
+ "version": "2.73.1",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — 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": {
@@ -33,6 +33,7 @@
33
33
  "test": "tsx --test test/*.test.ts",
34
34
  "test:watch": "tsx --test --watch test/*.test.ts",
35
35
  "self-smoke": "tsx scripts/self-smoke.mts",
36
+ "qwen-smoke": "tsx scripts/qwen-smoke.mts",
36
37
  "render-demo": "tsx scripts/render-demo.mts",
37
38
  "build:readme": "tsx scripts/build-readme.mts",
38
39
  "postinstall": "node scripts/postinstall.mjs",
@@ -0,0 +1,189 @@
1
+ // Qwen (chat.qwen.ai) self-smoke: a REAL isolated QwenBrowser instance against
2
+ // the live site, to catch integration regressions (login, send, answer) that
3
+ // unit tests cannot.
4
+ //
5
+ // Isolation: HOME is pointed at a throwaway dir BEFORE importing any project
6
+ // module (they compute ~/.zames paths at load). The real Qwen profile
7
+ // (~/.zames/qwen-profile) is COPIED read-only into the temp HOME so the
8
+ // browser starts already signed in; the live profile is NEVER opened. The
9
+ // Playwright browser cache is symlinked read-only. Nothing in the operator's
10
+ // ~/.zames is written.
11
+ //
12
+ // Usage: npx tsx scripts/qwen-smoke.mts [--headed] [--no-reuse-profile]
13
+ // Exit code 0 = all scenarios passed; 2 = login failed (could not start).
14
+
15
+ import fs from 'fs'
16
+ import path from 'path'
17
+ import os from 'os'
18
+
19
+ const HEADED = process.argv.includes('--headed')
20
+ const REUSE_PROFILE = !process.argv.includes('--no-reuse-profile')
21
+ const REAL_HOME = os.homedir()
22
+ const PROFILE_SRC = path.join(REAL_HOME, '.zames', 'qwen-profile')
23
+
24
+ if (!process.env.ZAMES_QWEN_SMOKE_HOME) {
25
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-qwen-smoke-'))
26
+ process.env.HOME = tmp
27
+ process.env.ZAMES_QWEN_SMOKE_HOME = tmp
28
+ fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
29
+ fs.mkdirSync(path.join(tmp, '.cache'), { recursive: true })
30
+ const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
31
+ if (fs.existsSync(realCfg)) {
32
+ fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
33
+ }
34
+ const realPw = path.join(REAL_HOME, '.cache', 'ms-playwright')
35
+ if (fs.existsSync(realPw)) {
36
+ fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
37
+ }
38
+ if (REUSE_PROFILE && fs.existsSync(PROFILE_SRC)) {
39
+ fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'qwen-profile'), {
40
+ recursive: true,
41
+ filter: (src) => !/^Singleton/.test(path.basename(src)),
42
+ })
43
+ }
44
+ }
45
+ const SMOKE_HOME = process.env.ZAMES_QWEN_SMOKE_HOME as string
46
+
47
+ // --- project modules (dynamic: they read HOME at import) --------------------
48
+ const { QwenBrowser } = await import('../src/qwen-browser.ts')
49
+ const { loadConfig } = await import('../src/config.ts')
50
+
51
+ const cfg = loadConfig()
52
+
53
+ interface SmokeResult {
54
+ name: string
55
+ ok: boolean
56
+ note?: string
57
+ }
58
+ const results: SmokeResult[] = []
59
+ let loginFailed = false
60
+ const record = (name: string, ok: boolean, note?: string): void => {
61
+ results.push({ name, ok, note })
62
+ console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
63
+ }
64
+
65
+ const browser = new QwenBrowser({
66
+ headless: !HEADED,
67
+ debug: process.env.ZAMES_QWEN_SMOKE_DEBUG === '1',
68
+ channel: cfg.browserChannel,
69
+ auth: cfg.browser.auth,
70
+ locale: 'ru',
71
+ minSendIntervalMs: 0,
72
+ answerTimeoutMs: 180000,
73
+ webSearch: false,
74
+ deepThinking: false,
75
+ })
76
+ browser.onNotice = (t: string) => console.log(' [notice] ' + t)
77
+
78
+ async function main() {
79
+ console.log(
80
+ 'qwen-smoke: HOME=' +
81
+ SMOKE_HOME +
82
+ ' headed=' +
83
+ HEADED +
84
+ ' reuse-profile=' +
85
+ REUSE_PROFILE,
86
+ )
87
+ await browser.launch()
88
+
89
+ // Bound the login wait so a cold/expired session fails fast with a clear
90
+ // hint instead of hanging until the caller's tool timeout.
91
+ const loginPromise = browser.waitForLogin()
92
+ const timedOut = await Promise.race([
93
+ loginPromise.then(() => false),
94
+ new Promise<boolean>((r) => setTimeout(() => r(true), 60000)),
95
+ ])
96
+ if (timedOut) {
97
+ loginFailed = true
98
+ record('login', false, 'timed out after 60s')
99
+ } else {
100
+ const logged = await browser.isLoggedIn().catch(() => false)
101
+ record('login', logged, logged ? '' : 'not signed in (guest)')
102
+ if (!logged) loginFailed = true
103
+ }
104
+
105
+ if (!loginFailed) {
106
+ // 1) new chat + a SIMPLE ask that needs no tools: the model must answer.
107
+ try {
108
+ await browser.newChat()
109
+ const id = await browser.getCurrentChatId()
110
+ record('newChat', true, 'id=' + (id || '(none)'))
111
+ } catch (e) {
112
+ record('newChat', false, (e as Error).message)
113
+ }
114
+
115
+ try {
116
+ const ans = await browser.ask('Reply with exactly: PONG', {
117
+ agent: true,
118
+ })
119
+ const ok = /PONG/i.test(ans)
120
+ record('ask:simple', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
121
+ } catch (e) {
122
+ record('ask:simple', false, (e as Error).message)
123
+ }
124
+
125
+ // 2) a second send right after the first: guards the send-verify path (the
126
+ // "text entered but send button not pressed" regression).
127
+ try {
128
+ const ans = await browser.ask('Reply with exactly: PONG2', {
129
+ agent: true,
130
+ })
131
+ const ok = /PONG2/i.test(ans)
132
+ record('ask:second-send', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
133
+ } catch (e) {
134
+ record('ask:second-send', false, (e as Error).message)
135
+ }
136
+
137
+ // 3) model catalogue + token usage (best-effort, non-fatal).
138
+ try {
139
+ const models = await browser.listModels()
140
+ record('listModels', models.length > 0, 'n=' + models.length)
141
+ } catch (e) {
142
+ record('listModels', false, (e as Error).message)
143
+ }
144
+
145
+ const usage = browser.getLastTokenUsage()
146
+ record('tokenUsage', usage !== null, 'usage=' + String(usage))
147
+
148
+ // 4) tool-call emission: the agent-loop depends on the model returning a
149
+ // parseable JSON tool call. Ask for one explicitly and check the PARSER
150
+ // (not the model's prose).
151
+ try {
152
+ const { parseToolCall } = await import('../src/answer-parse.ts')
153
+ const ans = await browser.ask(
154
+ 'Call the Read tool for the file package.json. Reply with EXACTLY one ' +
155
+ 'JSON tool-call object and nothing else: ' +
156
+ '{"tool":"Read","args":{"path":"..."}}',
157
+ { agent: true },
158
+ )
159
+ const parsed = parseToolCall(ans)
160
+ const call = Array.isArray(parsed) ? parsed[0] : parsed
161
+ const ok = !!call && call.tool === 'Read'
162
+ record('toolCall', ok, ok ? '' : 'got: ' + ans.slice(0, 100))
163
+ } catch (e) {
164
+ record('toolCall', false, (e as Error).message)
165
+ }
166
+ }
167
+
168
+ await browser.close().catch(() => {})
169
+
170
+ const failed = results.filter((r) => !r.ok)
171
+ console.log(
172
+ '\nqwen-smoke: ' +
173
+ (results.length - failed.length) +
174
+ '/' +
175
+ results.length +
176
+ ' passed',
177
+ )
178
+ if (!process.env.ZAMES_QWEN_SMOKE_KEEP) {
179
+ fs.rmSync(SMOKE_HOME, { recursive: true, force: true })
180
+ } else {
181
+ console.log('kept HOME: ' + SMOKE_HOME)
182
+ }
183
+ process.exit(loginFailed ? 2 : failed.length ? 1 : 0)
184
+ }
185
+
186
+ main().catch((e) => {
187
+ console.error('qwen-smoke crashed:', (e as Error).message)
188
+ process.exit(1)
189
+ })