zames_pro 2.72.9 → 2.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,28 +5,28 @@ export const deepseekMessages = {
5
5
  // printed around the moment they happen (browser.ts / agent-loop.ts), not as
6
6
  // a bare error at the end of the run.
7
7
  'ds.rate_limit_wait': {
8
- ru: '⏳ DeepSeek: «слишком часто». Жду {min} мин ({attempt}/{max}) и повторю отправку...',
9
- en: '⏳ DeepSeek: "too frequent". Waiting {min} min ({attempt}/{max}), then resending...',
8
+ ru: '⏳ {provider}: «слишком часто». Жду {min} мин ({attempt}/{max}) и повторю отправку...',
9
+ en: '⏳ {provider}: "too frequent". Waiting {min} min ({attempt}/{max}), then resending...',
10
10
  },
11
11
  'ds.rate_limit_give_up': {
12
- ru: '✖ DeepSeek не принял сообщение после {attempt} пауз по {min} мин. Подожди и попробуй снова.',
13
- en: '✖ DeepSeek refused the message after {attempt} waits of {min} min. Wait and try again.',
12
+ ru: '✖ {provider} не принял сообщение после {attempt} пауз по {min} мин. Подожди и попробуй снова.',
13
+ en: '✖ {provider} refused the message after {attempt} waits of {min} min. Wait and try again.',
14
14
  },
15
15
  'ds.server_busy_wait': {
16
- ru: '⏳ DeepSeek: сервер занят. Жду {sec}с ({attempt}/{max}) и повторю...',
17
- en: '⏳ DeepSeek: server busy. Waiting {sec}s ({attempt}/{max}), then retrying...',
16
+ ru: '⏳ {provider}: сервер занят. Жду {sec}с ({attempt}/{max}) и повторю...',
17
+ en: '⏳ {provider}: server busy. Waiting {sec}s ({attempt}/{max}), then retrying...',
18
18
  },
19
19
  'ds.server_busy_give_up': {
20
- ru: '✖ DeepSeek: сервер не ответил после {attempt} повторов. Попробуй позже.',
21
- en: '✖ DeepSeek: the server did not respond after {attempt} retries. Try again later.',
20
+ ru: '✖ {provider}: сервер не ответил после {attempt} повторов. Попробуй позже.',
21
+ en: '✖ {provider}: the server did not respond after {attempt} retries. Try again later.',
22
22
  },
23
23
  'ds.ask_retry': {
24
24
  ru: '⚠ Отправка не удалась (попытка {attempt}/{max} на этот запрос): {error} — повторяю...',
25
25
  en: '⚠ Send failed (attempt {attempt}/{max} for this request): {error} — retrying...',
26
26
  },
27
27
  'ds.incomplete_retry': {
28
- ru: '⏳ DeepSeek оборвал ответ и кнопки Continue нет — переотправляю задачу ({attempt}/{max})...',
29
- en: '⏳ DeepSeek truncated the answer and there is no Continue button — resending the task ({attempt}/{max})...',
28
+ ru: '⏳ {provider} оборвал ответ и кнопки Continue нет — переотправляю задачу ({attempt}/{max})...',
29
+ en: '⏳ {provider} truncated the answer and there is no Continue button — resending the task ({attempt}/{max})...',
30
30
  },
31
31
  'ds.attach_read_failed': {
32
32
  ru: '⚠ не удалось прочитать вложение {name}: {error}',
@@ -41,8 +41,8 @@ export const deepseekMessages = {
41
41
  en: '⚠ could not attach the files: {error}',
42
42
  },
43
43
  'ds.incomplete_give_up': {
44
- ru: '✖ DeepSeek {attempt} раз подряд оборвал ответ, не дойдя до конца. Возможно, включён режим размышления — попробуй выключить его (browser.deepThinking) или повтори позже.',
45
- en: '✖ DeepSeek truncated the answer {attempt} times in a row. The reasoning mode (deep thinking) may be on — try turning it off (browser.deepThinking) or retry later.',
44
+ ru: '✖ {provider} {attempt} раз подряд оборвал ответ, не дойдя до конца. Возможно, включён режим размышления — попробуй выключить его (browser.deepThinking) или повтори позже.',
45
+ en: '✖ {provider} truncated the answer {attempt} times in a row. The reasoning mode (deep thinking) may be on — try turning it off (browser.deepThinking) or retry later.',
46
46
  },
47
47
  'ds.ask_restart_browser': {
48
48
  ru: '⚠ Браузер потерял страницу — перезапускаю и вхожу заново...',
@@ -53,36 +53,36 @@ export const deepseekMessages = {
53
53
  en: '⚠ Could not restart the browser: {error}',
54
54
  },
55
55
  'ds.ask_failed': {
56
- ru: '✖ Не удалось получить ответ от DeepSeek после {max} попыток: {error}',
57
- en: '✖ Could not get an answer from DeepSeek after {max} attempts: {error}',
56
+ ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
57
+ en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
58
58
  },
59
59
  'ds.input_missing': {
60
- ru: '✖ Не найдено поле ввода на странице DeepSeek. Запусти /debug-dom и поправь INPUT_SELECTORS.',
61
- en: '✖ The DeepSeek input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
60
+ ru: '✖ Не найдено поле ввода на странице {provider}. Запусти /debug-dom и поправь INPUT_SELECTORS.',
61
+ en: '✖ The {provider} input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
62
62
  },
63
63
  'ds.input_partial': {
64
64
  ru: '✖ Не удалось вставить текст в поле ввода целиком ({got} из {want} символов). Сообщение не отправлено, чтобы не отправить обрезанный текст. Попробуй ещё раз или разбей сообщение.',
65
65
  en: '✖ Could not paste the whole text into the input ({got} of {want} chars). The message was not sent to avoid sending a truncated text. Retry or split the message.',
66
66
  },
67
67
  'ds.send_no_start': {
68
- ru: '✖ Ответ не начал генерироваться за 35с даже после повторной отправки. Проверь чат DeepSeek вручную (возможно, кнопка отправки не нажимается или сессия разлогинилась).',
69
- en: '✖ The answer did not start generating within 35s even after resending. Check the DeepSeek chat manually (the send button may not be clickable or the session may have expired).',
68
+ ru: '✖ Ответ не начал генерироваться за 35с даже после повторной отправки. Проверь чат {provider} вручную (возможно, кнопка отправки не нажимается или сессия разлогинилась).',
69
+ en: '✖ The answer did not start generating within 35s even after resending. Check the {provider} chat manually (the send button may not be clickable or the session may have expired).',
70
70
  },
71
71
  'ds.send_no_new_answer': {
72
- ru: '✖ Новый ответ не получен — на странице остался прежний текст. Возможно, сообщение не отправилось. Проверь чат DeepSeek вручную.',
73
- en: '✖ No new answer received — the page still shows the previous text. The message may not have been sent. Check the DeepSeek chat manually.',
72
+ ru: '✖ Новый ответ не получен — на странице остался прежний текст. Возможно, сообщение не отправилось. Проверь чат {provider} вручную.',
73
+ en: '✖ No new answer received — the page still shows the previous text. The message may not have been sent. Check the {provider} chat manually.',
74
74
  },
75
75
  'ds.continue_clicked': {
76
- ru: '▶ Нажал «Continue» (DeepSeek приостановил вывод — возобновляю).',
77
- en: '▶ Clicked "Continue" (DeepSeek paused the output — resuming).',
76
+ ru: '▶ Нажал «Continue» ({provider} приостановил вывод — возобновляю).',
77
+ en: '▶ Clicked "Continue" ({provider} paused the output — resuming).',
78
78
  },
79
79
  'ds.answer_timeout': {
80
- ru: '⏳ DeepSeek не ответил за {sec}с ({attempt}/{max}) — повторяю запрос...',
81
- en: '⏳ DeepSeek did not answer within {sec}s ({attempt}/{max}) — retrying the request...',
80
+ ru: '⏳ {provider} не ответил за {sec}с ({attempt}/{max}) — повторяю запрос...',
81
+ en: '⏳ {provider} did not answer within {sec}s ({attempt}/{max}) — retrying the request...',
82
82
  },
83
83
  'ds.answer_timeout_give_up': {
84
- ru: '✖ DeepSeek перестал отвечать, лимит повторов исчерпан. Модель не дала ответа — проверь чат DeepSeek вручную.',
85
- en: '✖ DeepSeek stopped responding, retry limit exhausted. The model gave no answer — check the DeepSeek chat manually.',
84
+ ru: '✖ {provider} перестал отвечать, лимит повторов исчерпан. Модель не дала ответа — проверь чат вручную.',
85
+ en: '✖ {provider} stopped responding, retry limit exhausted. The model gave no answer — check the chat manually.',
86
86
  },
87
87
  'ds.tool_retry': {
88
88
  ru: '⏳ Агент не распознал ответ модели — прошу продолжить ({attempt}/{max})...',
package/dist/i18n/help.js CHANGED
@@ -423,6 +423,14 @@ export const helpMessages = {
423
423
  ru: '/config lang <ru|en> сменить язык интерфейса и агента',
424
424
  en: '/config lang <ru|en> switch UI and agent language',
425
425
  },
426
+ 'help.cmd.provider': {
427
+ ru: '/provider <deepseek|qwen> переключить провайдера чата',
428
+ en: '/provider <deepseek|qwen> switch the chat provider',
429
+ },
430
+ 'help.cmd.model': {
431
+ ru: '/model [id] показать/выбрать модель Qwen',
432
+ en: '/model [id] show/pick the Qwen model',
433
+ },
426
434
  'help.cmd.debug_dom': {
427
435
  ru: '/debug-dom сохранить HTML страницы (для отладки)',
428
436
  en: '/debug-dom dump page HTML (debug)',
@@ -0,0 +1,85 @@
1
+ // Qwen (chat.qwen.ai) provider messages — everything the operator sees while
2
+ // the active backend is Qwen. Kept in its own section file (like deepseek.ts)
3
+ // so provider-specific wording has one home.
4
+ export const qwenMessages = {
5
+ 'qwen.guest_mode': {
6
+ ru: 'Qwen: вход не выполнен, работаю в гостевом режиме (≈10 сообщений в сутки, без вложений). Войдите через /provider login или запустите с --headed.',
7
+ en: 'Qwen: not signed in, running in guest mode (~10 messages/day, no attachments). Log in via /provider login or launch with --headed.',
8
+ },
9
+ 'qwen.login_email_prompt': {
10
+ ru: 'Email для входа в Qwen{def}: ',
11
+ en: 'Qwen login email{def}: ',
12
+ },
13
+ 'qwen.login_code_prompt': {
14
+ ru: 'Код из письма Qwen: ',
15
+ en: 'Qwen email code: ',
16
+ },
17
+ 'qwen.login_pass_prompt': {
18
+ ru: 'Пароль Qwen: ',
19
+ en: 'Qwen password: ',
20
+ },
21
+ 'qwen.login_ok': {
22
+ ru: 'Qwen: вход выполнен.',
23
+ en: 'Qwen: signed in.',
24
+ },
25
+ 'qwen.login_failed': {
26
+ ru: 'Qwen: войти не удалось. Войдите вручную в окне браузера (запустите с --headed) и повторите.',
27
+ en: 'Qwen: sign-in failed. Sign in manually in the browser window (launch with --headed) and retry.',
28
+ },
29
+ 'qwen.attach_unsupported': {
30
+ ru: 'Qwen: вложения пока не поддерживаются — файлы не отправлены.',
31
+ en: 'Qwen: attachments are not supported yet — files were not sent.',
32
+ },
33
+ 'qwen.guest_limit': {
34
+ ru: 'Qwen: исчерпан суточный лимит гостя. Войдите в аккаунт (email / Google / GitHub) командой /provider login или запустите с --headed.',
35
+ en: 'Qwen: the guest daily limit is exhausted. Sign in (email / Google / GitHub) with /provider login, or launch with --headed.',
36
+ },
37
+ 'qwen.restart_browser': {
38
+ ru: '⚠ Qwen: страница браузера потеряна — перезапускаю и вхожу заново...',
39
+ en: '⚠ Qwen: the browser lost the page — restarting and signing in again...',
40
+ },
41
+ 'qwen.restart_failed': {
42
+ ru: '⚠ Qwen: не удалось перезапустить браузер: {error}',
43
+ en: '⚠ Qwen: could not restart the browser: {error}',
44
+ },
45
+ 'qwen.model_set': {
46
+ ru: 'Модель Qwen: {v}',
47
+ en: 'Qwen model: {v}',
48
+ },
49
+ 'qwen.model_list': {
50
+ ru: 'Доступные модели Qwen:\n{v}',
51
+ en: 'Available Qwen models:\n{v}',
52
+ },
53
+ 'qwen.model_error': {
54
+ ru: 'Не удалось получить список моделей Qwen: {v}',
55
+ en: 'Failed to fetch the Qwen model list: {v}',
56
+ },
57
+ 'provider.switched': {
58
+ ru: 'Провайдер чата: {v}. Перезапустите zames, чтобы сменить браузер.',
59
+ en: 'Chat provider: {v}. Restart zames to switch the browser.',
60
+ },
61
+ 'provider.usage': {
62
+ ru: 'Использование: /provider <deepseek|qwen> | /provider login | /provider status',
63
+ en: 'Usage: /provider <deepseek|qwen> | /provider login | /provider status',
64
+ },
65
+ '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.',
68
+ },
69
+ 'provider.handoff_used': {
70
+ ru: '📎 Подхватил контекст предыдущего провайдера (carried-over summary).',
71
+ en: '📎 Carried over the previous provider context (handoff summary).',
72
+ },
73
+ 'provider.handoff_empty': {
74
+ ru: 'Не удалось сжать историю — контекст НЕ перенесён. Текущий чат останется у старого провайдера.',
75
+ en: 'Could not summarize the chat — context NOT carried over. The current chat stays with the old provider.',
76
+ },
77
+ 'provider.login_done': {
78
+ ru: 'Проверка входа завершена.',
79
+ en: 'Login check complete.',
80
+ },
81
+ 'provider.status': {
82
+ ru: 'Провайдер: {v}, модель: {m}, вход: {auth}',
83
+ en: 'Provider: {v}, model: {m}, login: {auth}',
84
+ },
85
+ };
package/dist/i18n.js CHANGED
@@ -16,6 +16,7 @@ import { selfReviewMessages } from './i18n/self-review.js';
16
16
  import { mcpMessages } from './i18n/mcp.js';
17
17
  import { configMessages } from './i18n/config.js';
18
18
  import { promptMessages } from './i18n/prompt.js';
19
+ import { qwenMessages } from './i18n/qwen.js';
19
20
  export const DEFAULT_LOCALE = 'ru';
20
21
  export const LOCALES = [
21
22
  { code: 'ru', name: 'Русский', englishName: 'Russian' },
@@ -47,6 +48,7 @@ export const CATALOG = {
47
48
  ...mcpMessages,
48
49
  ...configMessages,
49
50
  ...promptMessages,
51
+ ...qwenMessages,
50
52
  };
51
53
  export function translate(locale) {
52
54
  const loc = isLocale(locale) ? locale : DEFAULT_LOCALE;
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import { existsSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
+ import { QwenBrowser } from './qwen-browser.js';
10
+ import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, } from './providers.js';
9
11
  import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
10
12
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
11
13
  import { runAgentLoop } from './agent-loop.js';
@@ -14,7 +16,7 @@ import { createSpinner } from './spinner.js';
14
16
  import { LineEditor } from './input.js';
15
17
  import { promptOnce } from './prompt.js';
16
18
  import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
17
- import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
19
+ import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, providerAskDeadlineMs, } from './config.js';
18
20
  import { runConfigMenu } from './config-menu.js';
19
21
  import { formatConfigList } from './config-commands.js';
20
22
  import { handleConfigCommand as runConfigCommand } from './config-command.js';
@@ -24,6 +26,7 @@ import { UndoStore } from './undo.js';
24
26
  import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
25
27
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
26
28
  import { performCompact } from './compact.js';
29
+ import { buildHandoffText, saveProviderHandoff, readProviderHandoff, clearProviderHandoff, } from './provider-handoff.js';
27
30
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
28
31
  import { createCliArgs } from './cli-args.js';
29
32
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
@@ -121,10 +124,14 @@ if (jsonMode) {
121
124
  function saveLastChat(id, workdir = '', title = '') {
122
125
  if (!id)
123
126
  return;
124
- saveSession({ id, workdir, title });
127
+ // Tag the session with the ACTIVE provider: a chat id is only meaningful to
128
+ // the backend that created it, so a later resume must open it in the same
129
+ // one (or refuse). Without this a /provider switch made /resume try to open a
130
+ // qwen id in DeepSeek (and vice versa).
131
+ saveSession({ id, workdir, title, provider: config.provider });
125
132
  }
126
133
  function loadLastChat(workdir = '') {
127
- const s = loadLastSession(workdir);
134
+ const s = loadLastSession(workdir, config.provider);
128
135
  return s ? s.id : null;
129
136
  }
130
137
  // Persistent per-project session goal (set by /goal). Kept OUT of the session
@@ -591,6 +598,21 @@ async function resolveWorkdir() {
591
598
  return dir;
592
599
  }
593
600
  // ---------- restored dialogue ----------
601
+ /**
602
+ * The answer timeout for a one-off provider call (compact summary, etc.).
603
+ * Qwen's cold guest turns are far slower than DeepSeek's, so using
604
+ * config.browser.answerTimeoutMs there cut them off; pick the active
605
+ * provider's own value.
606
+ */
607
+ function providerAnswerTimeoutMs() {
608
+ return config.provider === 'qwen'
609
+ ? config.qwen.answerTimeoutMs
610
+ : config.browser.answerTimeoutMs;
611
+ }
612
+ /** The provider's base chat URL, used by /chat to build the right link. */
613
+ function browserProviderUrl() {
614
+ return providerChatUrlPrefix(config.provider);
615
+ }
594
616
  /**
595
617
  * Print the dialogue of the just-opened chat into the terminal. Called by
596
618
  * /resume, /resume-id and on startup when a previous session is restored, so
@@ -757,24 +779,52 @@ async function main() {
757
779
  catch (e) {
758
780
  console.error(theme.warn(t('mcp.load_failed', { v: e.message })));
759
781
  }
760
- const browser = new DeepSeekBrowser({
761
- headless,
762
- debug,
763
- channel: config.browserChannel,
764
- ...config.browser,
765
- locale: currentLocale,
766
- // Persist credentials the operator typed in the terminal so the next
767
- // launch can re-login silently after a logout.
768
- onAuthSave: (username, password) => {
769
- config.browser.auth.username = username;
770
- config.browser.auth.password = password;
771
- try {
772
- writeConfigValue('home', 'browser.auth.username', username);
773
- writeConfigValue('home', 'browser.auth.password', password);
774
- }
775
- catch { }
776
- },
777
- });
782
+ // Build the browser for the ACTIVE provider. The two backends differ in
783
+ // every layer (selectors, SSE format, chat-id scheme), so the switch is a
784
+ // class choice, not a flag. The shared timings live in config.browser; only
785
+ // the model is provider-specific. A provider switch takes effect on the
786
+ // 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
+ });
778
828
  const bootSpinner = mod.createSpinner(currentLocale);
779
829
  bootSpinner.thinking();
780
830
  try {
@@ -821,7 +871,7 @@ async function main() {
821
871
  // explicitly: --chat <id> or --resume-last.
822
872
  let resumeId = chatIdArg;
823
873
  if (!resumeId && resumeLastFlag && !newChatFlag) {
824
- const last = loadLastSession(currentWorkdir);
874
+ const last = loadLastSession(currentWorkdir, config.provider);
825
875
  if (last && last.id) {
826
876
  resumeId = last.id;
827
877
  console.log(theme.system(t('msg.resuming', { id: resumeId })));
@@ -937,7 +987,7 @@ async function main() {
937
987
  },
938
988
  transcript,
939
989
  maxSubagents: config.browser.maxSubagents,
940
- askDeadlineMs: config.browser.askDeadlineMs,
990
+ askDeadlineMs: providerAskDeadlineMs(config),
941
991
  maxAfterToolRetries: config.browser.maxAfterToolRetries,
942
992
  // An `ask` permission rule must also gate the subagent: otherwise a
943
993
  // guarded tool (e.g. a dangerous Bash command) would run inside the
@@ -1075,7 +1125,7 @@ async function main() {
1075
1125
  // --new-chat kept for compatibility (this is the default behavior)
1076
1126
  let resumeId = chatIdArg;
1077
1127
  if (!resumeId && resumeLastFlag && !newChatFlag) {
1078
- const last = loadLastSession(currentWorkdir);
1128
+ const last = loadLastSession(currentWorkdir, config.provider);
1079
1129
  if (last && last.id) {
1080
1130
  resumeId = last.id;
1081
1131
  console.log(theme.system(t('msg.resuming', {
@@ -1137,6 +1187,8 @@ async function main() {
1137
1187
  });
1138
1188
  editor = ed;
1139
1189
  ed.setTmpDir(TMP_DIR);
1190
+ // Provider badge: show which backend is answering ([Qwen]/[DeepSeek]).
1191
+ ed.setProvider(providerLabel(config.provider));
1140
1192
  // Restore the input history from the previous runs and persist every new
1141
1193
  // entry, so Ctrl+R / arrows can recall older messages after a restart.
1142
1194
  ed.history = loadHistory();
@@ -1983,10 +2035,133 @@ async function main() {
1983
2035
  }
1984
2036
  continue;
1985
2037
  }
2038
+ if (lower === '/provider' || lower.startsWith('/provider ')) {
2039
+ const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2040
+ if (!arg || arg === 'status') {
2041
+ const model = config.provider === 'qwen' ? config.qwen.model || '(default)' : 'n/a';
2042
+ console.log(theme.system(t('provider.status', {
2043
+ v: config.provider,
2044
+ m: model,
2045
+ auth: authMarkerExists() ? 'yes' : 'no',
2046
+ })));
2047
+ continue;
2048
+ }
2049
+ if (arg === 'login') {
2050
+ try {
2051
+ await browser.waitForLogin();
2052
+ console.log(theme.system(t('provider.login_done')));
2053
+ }
2054
+ catch (e) {
2055
+ console.error(theme.warn(e.message));
2056
+ }
2057
+ continue;
2058
+ }
2059
+ if (isProviderId(arg)) {
2060
+ const from = config.provider;
2061
+ if (arg === from) {
2062
+ console.log(theme.system(t('provider.switched', { v: arg })));
2063
+ continue;
2064
+ }
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;
2083
+ }
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 });
2097
+ }
2098
+ }
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')));
2112
+ }
2113
+ }
2114
+ finally {
2115
+ if (editor)
2116
+ editor.unlock();
2117
+ }
2118
+ // Tag the current session with the OLD provider before switching.
2119
+ if (currentChatId)
2120
+ saveLastChat(currentChatId, currentWorkdir);
2121
+ config.provider = arg;
2122
+ config.qwen.enabled = arg === 'qwen';
2123
+ try {
2124
+ writeConfigValue('home', 'provider', arg);
2125
+ }
2126
+ catch { }
2127
+ console.log(theme.system(t('provider.switched', { v: arg })));
2128
+ continue;
2129
+ }
2130
+ console.error(theme.warn(t('provider.usage')));
2131
+ continue;
2132
+ }
2133
+ if (lower === '/model' || lower.startsWith('/model ')) {
2134
+ const arg = trimmed.slice('/model'.length).trim();
2135
+ if (config.provider !== 'qwen') {
2136
+ console.log(theme.system(t('provider.usage')));
2137
+ continue;
2138
+ }
2139
+ const qb = browser;
2140
+ if (!arg) {
2141
+ const models = qb.listModels ? await qb.listModels() : [];
2142
+ if (!models.length) {
2143
+ console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2144
+ }
2145
+ else {
2146
+ const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2147
+ console.log(theme.system(t('qwen.model_list', { v: lines })));
2148
+ }
2149
+ continue;
2150
+ }
2151
+ config.qwen.model = arg;
2152
+ if (typeof qb.model === 'string')
2153
+ qb.model = arg;
2154
+ try {
2155
+ writeConfigValue('home', 'qwen.model', arg);
2156
+ }
2157
+ catch { }
2158
+ console.log(theme.system(t('qwen.model_set', { v: arg })));
2159
+ continue;
2160
+ }
1986
2161
  if (lower === '/chat') {
1987
2162
  if (currentChatId) {
1988
2163
  console.log(theme.system(t('chats.current_id', { v: currentChatId })));
1989
- console.log(theme.system(`URL: https://chat.deepseek.com/a/chat/s/${currentChatId}`));
2164
+ console.log(theme.system(`URL: ${browserProviderUrl()}/${currentChatId}`));
1990
2165
  }
1991
2166
  else {
1992
2167
  const id = await browser.getCurrentChatId();
@@ -2028,7 +2203,7 @@ async function main() {
2028
2203
  if (lower === '/last') {
2029
2204
  // Quick resume of the last chat for THIS working directory — otherwise
2030
2205
  // finding it meant /chats (a network fetch) or copying the id.
2031
- const last = loadLastSession(currentWorkdir);
2206
+ const last = loadLastSession(currentWorkdir, config.provider);
2032
2207
  if (!last || !last.id) {
2033
2208
  console.error(theme.error(t('sessions.none')));
2034
2209
  continue;
@@ -2721,6 +2896,8 @@ async function main() {
2721
2896
  uiPresent: uiHealth?.present,
2722
2897
  uiMissing: uiHealth?.missing,
2723
2898
  uiMissingCritical: uiHealth?.missingCritical,
2899
+ provider: config.provider,
2900
+ providerModel: config.provider === 'qwen' ? config.qwen.model : undefined,
2724
2901
  }, t)));
2725
2902
  continue;
2726
2903
  }
@@ -2755,7 +2932,7 @@ async function main() {
2755
2932
  ui: editor,
2756
2933
  buildSystemPrompt: mod.buildSystemPrompt,
2757
2934
  tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
2758
- answerTimeoutMs: config.browser.answerTimeoutMs,
2935
+ answerTimeoutMs: providerAnswerTimeoutMs(),
2759
2936
  fallbackLimit: COMPACT_FALLBACK_LIMIT,
2760
2937
  selfImprovement: devMode,
2761
2938
  });
@@ -3112,6 +3289,27 @@ async function main() {
3112
3289
  }
3113
3290
  }
3114
3291
  lastTaskText = finalTaskText;
3292
+ // Provider handoff: if the operator switched providers, the new backend
3293
+ // has NO chat history. Post the carried-over summary as a preface so the
3294
+ // model continues instead of starting cold. Consumed once.
3295
+ {
3296
+ const handoff = readProviderHandoff(config.provider, currentWorkdir);
3297
+ if (handoff) {
3298
+ finalTaskText =
3299
+ buildHandoffText({
3300
+ from: handoff.provider === 'qwen' ? 'DeepSeek' : 'Qwen',
3301
+ to: providerLabel(config.provider),
3302
+ summary: handoff.summary,
3303
+ task: handoff.task,
3304
+ }) +
3305
+ String.fromCharCode(10, 10) +
3306
+ 'Now continue with this task:' +
3307
+ String.fromCharCode(10) +
3308
+ finalTaskText;
3309
+ clearProviderHandoff();
3310
+ console.log(theme.system(t('provider.handoff_used')));
3311
+ }
3312
+ }
3115
3313
  // Dev mode: pick up fresh logic modules before the task.
3116
3314
  await autoReload();
3117
3315
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3185,7 +3383,7 @@ async function main() {
3185
3383
  undo,
3186
3384
  todos: todoStore,
3187
3385
  }),
3188
- answerTimeoutMs: config.browser.answerTimeoutMs,
3386
+ answerTimeoutMs: providerAnswerTimeoutMs(),
3189
3387
  quiet: true,
3190
3388
  skipUiLock: true,
3191
3389
  selfImprovement: devMode,
package/dist/input.js CHANGED
@@ -171,6 +171,10 @@ export class LineEditor {
171
171
  // plain field because the CALLER runs the command on its own timer (a
172
172
  // per-render spawn would block typing).
173
173
  statusLine;
174
+ // Active chat provider label (e.g. "Qwen", "DeepSeek") shown as a small
175
+ // badge in the status line, so the operator always knows which backend is
176
+ // answering. Empty hides it. Set via setProvider().
177
+ providerBadge;
174
178
  // Whether the DeepSeek "Deep thinking" / "Smart search" toggles are ON.
175
179
  // Shown as two colored icons before the context counter: a dim gray icon
176
180
  // means off, a teal-green icon means on.
@@ -265,6 +269,7 @@ export class LineEditor {
265
269
  this.onContextQuery = null;
266
270
  this.queueLength = 0;
267
271
  this.statusLine = '';
272
+ this.providerBadge = '';
268
273
  this.onQueueQuery = null;
269
274
  this.onTasksQuery = null;
270
275
  }
@@ -353,6 +358,14 @@ export class LineEditor {
353
358
  _statusLineBadge() {
354
359
  return this.statusLine ? theme.system(this.statusLine) + ' ' : '';
355
360
  }
361
+ // A small badge with the ACTIVE provider (e.g. "Qwen"), shown before the
362
+ // toggles so the operator always knows which backend is answering (a
363
+ // multi-provider session can otherwise be ambiguous). Empty hides it.
364
+ _providerBadge() {
365
+ return this.providerBadge
366
+ ? theme.system('[' + this.providerBadge + ']') + ' '
367
+ : '';
368
+ }
356
369
  // Icons for the DeepSeek toggles: 🧠 "Deep thinking" and 🌐 "Smart search",
357
370
  // shown before the context counter. Only the ENABLED toggles are shown, so
358
371
  // the state is unambiguous WITHOUT relying on the terminal coloring the
@@ -593,6 +606,15 @@ export class LineEditor {
593
606
  this.statusLine = t;
594
607
  this._render();
595
608
  }
609
+ // Set the provider badge (e.g. "Qwen"). An empty string hides it. Repaints
610
+ // only when the value actually changes.
611
+ setProvider(text) {
612
+ const t = String(text || '').trim();
613
+ if (t === this.providerBadge)
614
+ return;
615
+ this.providerBadge = t;
616
+ this._render();
617
+ }
596
618
  setContextLimit(limit) {
597
619
  if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
598
620
  this.contextLimit = limit;
@@ -692,6 +714,7 @@ export class LineEditor {
692
714
  const ctxRaw = this._contextText();
693
715
  this._refreshToggles();
694
716
  const ctxText = this._statusLineBadge() +
717
+ this._providerBadge() +
695
718
  this._queueBadge() +
696
719
  this._tasksBadge() +
697
720
  this._toggleIcons() +