zames_pro 2.73.0 → 2.73.2

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.73.2] - 2026-10-11
11
+
12
+ ### Changed
13
+
14
+ - docs(readme): document Qwen everywhere and refresh the diagram
15
+
16
+ ## [2.73.1] - 2026-10-11
17
+
18
+ ### Fixed
19
+
20
+ - **qwen:** live provider switch, verified send, server-busy handling
21
+
10
22
  ## [2.73.0] - 2026-10-11
11
23
 
12
24
  ### Added
package/README.md CHANGED
@@ -63,27 +63,31 @@ zames does not call the model API. It launches a headless Chromium with a
63
63
  persistent profile, signs in to a chat web app like a human, types the task
64
64
  into the chat box, and reads the answer back.
65
65
 
66
- Two chat backends are available, switched with `/provider <deepseek|qwen>`
67
- (restart required): **DeepSeek** (`chat.deepseek.com`, the default) and
68
- **Qwen** (`chat.qwen.ai`). Each keeps its own persistent profile, so switching
69
- never signs the other one out.
66
+ Two chat backends are available, switched with `/provider <deepseek|qwen>`:
67
+ **DeepSeek** (`chat.deepseek.com`, the default) and **Qwen** (`chat.qwen.ai`).
68
+ The switch is **live** — zames closes the current backend and opens the other
69
+ one in place, no restart needed. Each keeps its own persistent profile
70
+ (`~/.zames/profile` and `~/.zames/qwen-profile`), so switching never signs the
71
+ other one out.
70
72
 
71
73
  <p align="center">
72
74
  <img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="how zames works" width="860">
73
75
  </p>
74
76
 
75
77
  In text: you type a task → zames sends the system prompt plus the task into
76
- the DeepSeek web chat → the model answers → zames parses the tool call, runs
77
- the tool locally, feeds the result back, and prints the final answer for you.
78
+ the active chat web app (DeepSeek or Qwen) → the model answers → zames parses
79
+ the tool call, runs the tool locally, feeds the result back, and prints the
80
+ final answer for you.
78
81
 
79
82
  Key pieces:
80
83
 
81
84
  - **The answer is read from the raw network stream** (SSE), not the rendered
82
85
  DOM, so tool-call JSON with template strings and escapes survives intact.
83
- - **A persistent profile** (`~/.zames/profile`) keeps you signed in; the
84
- headless User-Agent is patched so DeepSeek's CDN does not 403 the login.
85
- - **A send throttle** (15 s by default) keeps the web chat's rate limit happy
86
- during long tool-heavy runs.
86
+ - **A persistent per-provider profile** (`~/.zames/profile` for DeepSeek,
87
+ `~/.zames/qwen-profile` for Qwen) keeps you signed in; the headless
88
+ User-Agent is patched so the site's CDN does not 403 the login.
89
+ - **A send throttle** (15 s on DeepSeek, ~1 s on Qwen, which tolerates rapid
90
+ sends) keeps the web chat's rate limit happy during long tool-heavy runs.
87
91
  - **The agent is sandboxed** to the directory it was started in — no tool can
88
92
  read or write above it.
89
93
 
@@ -174,7 +178,7 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
174
178
  **Qwen** (`chat.qwen.ai`):
175
179
 
176
180
  ```bash
177
- /provider qwen # switch to Qwen (persisted); restart zames to take effect
181
+ /provider qwen # switch to Qwen (persisted) — applied live, no restart
178
182
  /provider deepseek # switch back
179
183
  /provider status # show the active provider / model / login state
180
184
  /provider login # (re)run the sign-in check
@@ -184,15 +188,18 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
184
188
 
185
189
  Notes:
186
190
 
187
- - The two backends use **separate persistent profiles**
188
- (`~/.zames/profile` for DeepSeek, `~/.zames/qwen-profile` for Qwen), so
189
- switching never signs the other one out. A provider switch takes effect on
190
- the next launch — the browser is created once at startup.
191
+ - The switch is **live**: zames closes the current backend and opens the other
192
+ one in place, so no restart is needed. The two backends use **separate
193
+ persistent profiles** (`~/.zames/profile` for DeepSeek, `~/.zames/qwen-profile`
194
+ for Qwen), so switching never signs the other one out.
195
+ - A chat id belongs to ONE backend, so the new provider opens a **fresh chat**.
196
+ If the previous chat had messages they are summarized and posted as a
197
+ handoff with the next task; an empty chat is switched silently.
191
198
  - Qwen works in **guest mode** without an account, but a guest is limited to a
192
199
  handful of messages per day; for long agent runs sign in (email / Google /
193
200
  GitHub — no phone required). zames auto-logins from the saved credentials or
194
201
  asks in the terminal, exactly like DeepSeek. `/model` changes the Qwen model
195
- at runtime (no restart).
202
+ at runtime.
196
203
  - File attachments on Qwen go through the mode menu's **Upload attachment**
197
204
  item; DeepSeek uses its own paperclip.
198
205
  - `/doctor` shows the active provider and the UI health of that provider's
@@ -343,9 +350,10 @@ Codex CLI:
343
350
  (zames-export-<stamp>.md by default).
344
351
  - /doctor — diagnose node, git, config, browser, clipboard, MCP and the
345
352
  active chat provider (it also reports that provider's UI health).
346
- - /provider <deepseek|qwen> — switch the chat backend (restart to apply);
347
- /provider status shows the active provider/model/login; /provider login
348
- re-runs the sign-in check. /model [id] lists or picks a Qwen model at runtime.
353
+ - /provider <deepseek|qwen> — switch the chat backend (applied live, no
354
+ restart); /provider status shows the active provider/model/login; /provider
355
+ login re-runs the sign-in check. /model [id] lists or picks a Qwen model at
356
+ runtime.
349
357
  - /add-dir <path> — validate an extra directory (the sandbox is fixed at
350
358
  startup; relaunch with --dir to write there).
351
359
  - /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
@@ -582,30 +590,33 @@ zames не вызывает API модели. Он запускает headless C
582
590
  профилем, входит в веб-чат как человек, печатает задачу в поле чата и читает
583
591
  ответ обратно.
584
592
 
585
- Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen>`
586
- (нужен перезапуск): **DeepSeek** (`chat.deepseek.com`, по умолчанию) и
587
- **Qwen** (`chat.qwen.ai`). У каждого свой постоянный профиль, поэтому
588
- переключение не разлогинивает другой.
593
+ Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen>`:
594
+ **DeepSeek** (`chat.deepseek.com`, по умолчанию) и **Qwen** (`chat.qwen.ai`).
595
+ Переключение **живое** — zames закрывает текущий бэкенд и открывает другой на
596
+ месте, без перезапуска. У каждого свой постоянный профиль
597
+ (`~/.zames/profile` и `~/.zames/qwen-profile`), поэтому переключение не
598
+ разлогинивает другой.
589
599
 
590
600
  <p align="center">
591
601
  <img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="как работает zames" width="860">
592
602
  </p>
593
603
 
594
604
  Словами: вы вводите задачу → zames отправляет системный промпт и задачу в
595
- веб-чат DeepSeek → модель отвечает → zames разбирает вызов инструмента,
596
- выполняет его локально, возвращает результат обратно и печатает финальный
597
- ответ.
605
+ активный веб-чат (DeepSeek или Qwen) → модель отвечает → zames разбирает
606
+ вызов инструмента, выполняет его локально, возвращает результат обратно и
607
+ печатает финальный ответ.
598
608
 
599
609
  Ключевые детали:
600
610
 
601
611
  - **Ответ читается из сырого сетевого потока** (SSE), а не из отрендеренного
602
612
  DOM, поэтому JSON вызова инструмента с шаблонными строками и экранированием
603
613
  остаётся целым.
604
- - **Постоянный профиль** (`~/.zames/profile`) держит вас залогиненным;
605
- User-Agent headless-браузера подменяется, чтобы CDN DeepSeek не отдавал 403
606
- на вход.
607
- - **Троттлинг отправки** (15 с по умолчанию) щадит рейт-лимит веб-чата во
608
- время долгих прогонов с инструментами.
614
+ - **Постоянный профиль на каждого провайдера** (`~/.zames/profile` у DeepSeek,
615
+ `~/.zames/qwen-profile` у Qwen) держит вас залогиненным; User-Agent
616
+ headless-браузера подменяется, чтобы CDN сайта не отдавал 403 на вход.
617
+ - **Троттлинг отправки** (15 с у DeepSeek, ~1 с у Qwen, который спокойно
618
+ переносит частые отправки) щадит рейт-лимит веб-чата во время долгих
619
+ прогонов с инструментами.
609
620
  - **Агент запесочен** в директорию запуска — ни один инструмент не может
610
621
  читать или писать выше неё.
611
622
 
@@ -698,7 +709,7 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
698
709
  **Qwen** (`chat.qwen.ai`):
699
710
 
700
711
  ```bash
701
- /provider qwen # переключиться на Qwen (сохраняется); нужен перезапуск
712
+ /provider qwen # переключиться на Qwen (сохраняется) — применяется сразу
702
713
  /provider deepseek # вернуться обратно
703
714
  /provider status # активный провайдер / модель / статус входа
704
715
  /provider login # повторно пройти проверку входа
@@ -708,15 +719,17 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
708
719
 
709
720
  Замечания:
710
721
 
711
- - У бэкендов **разные постоянные профили** (`~/.zames/profile` у DeepSeek,
712
- `~/.zames/qwen-profile` у Qwen), поэтому переключение не разлогинивает
713
- другой. Смена провайдера применяется при следующем запуске — браузер
714
- создаётся один раз на старте.
722
+ - Переключение **живое**: zames закрывает текущий бэкенд и открывает другой на
723
+ месте, перезапуск не нужен. У бэкендов **разные постоянные профили**
724
+ (`~/.zames/profile` у DeepSeek, `~/.zames/qwen-profile` у Qwen), поэтому
725
+ переключение не разлогинивает другой.
726
+ - id чата принадлежит ОДНОМУ бэкенду, поэтому новый провайдер открывает
727
+ **свежий чат**. Если в прошлом чате были сообщения, они сжимаются и
728
+ подставляются как handoff в следующую задачу; пустой чат переключается молча.
715
729
  - Qwen работает в **гостевом режиме** без аккаунта, но гостю доступно всего
716
730
  несколько сообщений в день; для долгих прогонов войдите (email / Google /
717
731
  GitHub — телефон не нужен). zames сам логинится по сохранённым кредам или
718
- спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на лету
719
- (без перезапуска).
732
+ спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на лету.
720
733
  - Вложения в Qwen идут через пункт **Upload attachment** в меню режима; в
721
734
  DeepSeek — своя скрепка.
722
735
  - `/doctor` показывает активный провайдер и состояние UI его страницы.
@@ -872,9 +885,10 @@ zames --task "..." --output-format jsonl \
872
885
  (по умолчанию zames-export-<stamp>.md).
873
886
  - /doctor — диагностика node, git, конфига, браузера, буфера обмена, MCP и
874
887
  активного провайдера чата (плюс состояние UI его страницы).
875
- - /provider <deepseek|qwen> — сменить бэкенд чата (нужен перезапуск);
876
- /provider status показывает активный провайдер/модель/вход; /provider login
877
- повторно проверяет вход. /model [id] покажет или выберет модель Qwen на лету.
888
+ - /provider <deepseek|qwen> — сменить бэкенд чата (применяется сразу, без
889
+ перезапуска); /provider status показывает активный провайдер/модель/вход;
890
+ /provider login повторно проверяет вход. /model [id] покажет или выберет
891
+ модель Qwen на лету.
878
892
  - /add-dir <path> — проверить дополнительную директорию (песочница
879
893
  фиксируется при старте; для записи туда перезапустите с --dir).
880
894
  - /resume <n> (после /chats) и /resume-id <id> — открыть чат и НАПЕЧАТАТЬ его
@@ -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.2",
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,7 +33,10 @@
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",
38
+ "render-how": "tsx scripts/render-how-it-works.mts",
39
+ "render-how": "tsx scripts/render-how-it-works.mts",
37
40
  "build:readme": "tsx scripts/build-readme.mts",
38
41
  "postinstall": "node scripts/postinstall.mjs",
39
42
  "prepare": "husky"
@@ -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
+ })
@@ -0,0 +1,129 @@
1
+ // Render docs/how-it-works.png — the architecture diagram used in README.md.
2
+ //
3
+ // Why a generator: the README must show BOTH chat backends (DeepSeek and
4
+ // Qwen), not just DeepSeek. A hand-drawn PNG drifts; this renders a fixed
5
+ // HTML page with headless Chromium so the diagram is reproducible and easy to
6
+ // update when a provider is added.
7
+ //
8
+ // Run: npm run render-how (tsx scripts/render-how-it-works.mts)
9
+ //
10
+ // Isolation: chromium.launch() uses a THROWAWAY in-memory context, never
11
+ // ~/.zames/profile (see AGENTS.md — never touch the live agent's browser).
12
+
13
+ import { chromium } from 'playwright'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
18
+ const out = path.join(root, 'docs', 'how-it-works.png')
19
+
20
+ const C = {
21
+ bg: '#23272e',
22
+ panel: '#2b303a',
23
+ border: '#3b4250',
24
+ text: '#cfc9b0',
25
+ dim: '#8a9bb5',
26
+ user: '#b4b8d0',
27
+ accent: '#7fc4f0',
28
+ ds: '#6f8ed0',
29
+ qwen: '#6fd0b0',
30
+ tool: '#c6a97e',
31
+ }
32
+
33
+ const W = 1300
34
+ const H = 360
35
+
36
+ const box = (
37
+ title: string,
38
+ sub: string,
39
+ color: string,
40
+ ): string =>
41
+ '<div class="box" style="border-color:' +
42
+ color +
43
+ '">' +
44
+ '<div class="box-t" style="color:' +
45
+ color +
46
+ '">' +
47
+ title +
48
+ '</div>' +
49
+ '<div class="box-s">' +
50
+ sub +
51
+ '</div></div>'
52
+
53
+ const html =
54
+ '<!doctype html><html><head><meta charset="utf-8"><style>' +
55
+ '@font-face{font-family:ZM;src:local("DejaVu Sans Mono")}' +
56
+ 'html,body{margin:0;padding:0;background:' +
57
+ C.bg +
58
+ '}' +
59
+ 'body{font-family:"DejaVu Sans Mono",monospace;color:' +
60
+ C.text +
61
+ ';-webkit-font-smoothing:antialiased}' +
62
+ '.wrap{box-sizing:border-box;width:' +
63
+ W +
64
+ 'px;height:' +
65
+ H +
66
+ 'px;padding:26px 30px}' +
67
+ '.row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
68
+ '.box{box-sizing:border-box;border:2px solid;border-radius:10px;padding:14px 16px;' +
69
+ 'background:' +
70
+ C.panel +
71
+ ';min-width:150px;text-align:center}' +
72
+ '.box-t{font-size:18px;font-weight:700;margin-bottom:6px}' +
73
+ '.box-s{font-size:12px;color:' +
74
+ C.dim +
75
+ ';line-height:1.5}' +
76
+ '.arrow{font-size:26px;color:' +
77
+ C.dim +
78
+ '}' +
79
+ '.flow-label{font-size:12px;color:' +
80
+ C.dim +
81
+ ';text-align:center;margin-top:4px}' +
82
+ '.providers{display:flex;flex-direction:column;gap:10px}' +
83
+ '.col{display:flex;flex-direction:column;align-items:center;gap:4px}' +
84
+ '.legend{margin-top:20px;font-size:12px;color:' +
85
+ C.dim +
86
+ ';text-align:center}' +
87
+ '.legend b{color:' +
88
+ C.text +
89
+ '}' +
90
+ '</style></head><body><div class="wrap">' +
91
+ '<div class="row">' +
92
+ // left: the operator
93
+ '<div class="col">' +
94
+ box('Operator', 'terminal task<br/>+ tool approval', C.user) +
95
+ '<div class="flow-label">types a task</div>' +
96
+ '</div>' +
97
+ '<div class="arrow">&#8594;</div>' +
98
+ // middle: zames
99
+ '<div class="col">' +
100
+ box(
101
+ 'zames',
102
+ 'sandboxed tools<br/>parses tool-calls<br/>runs them locally',
103
+ C.accent,
104
+ ) +
105
+ '<div class="flow-label">system prompt + task</div>' +
106
+ '</div>' +
107
+ '<div class="arrow">&#8644;</div>' +
108
+ // right: the two backends
109
+ '<div class="providers">' +
110
+ box('DeepSeek', 'chat.deepseek.com<br/>~/.zames/profile', C.ds) +
111
+ box('Qwen', 'chat.qwen.ai<br/>~/.zames/qwen-profile', C.qwen) +
112
+ '</div>' +
113
+ '</div>' +
114
+ '<div class="legend">zames drives a <b>real browser</b> over a persistent per-provider profile &mdash; no API key. ' +
115
+ 'Switch live with <b>/provider &lt;deepseek|qwen&gt;</b> (each chat id belongs to one backend).</div>' +
116
+ '</div></body></html>'
117
+
118
+ const browser = await chromium.launch({ headless: true })
119
+ try {
120
+ const page = await browser.newPage({
121
+ viewport: { width: W, height: H },
122
+ deviceScaleFactor: 2,
123
+ })
124
+ await page.setContent(html, { waitUntil: 'load' })
125
+ await page.screenshot({ path: out })
126
+ console.log('wrote', path.relative(root, out))
127
+ } finally {
128
+ await browser.close()
129
+ }