zames_pro 2.80.5 → 2.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,12 +2,15 @@
2
2
  import './color.js';
3
3
  import path from 'path';
4
4
  import fs from 'fs/promises';
5
- import { existsSync, statSync } from 'fs';
5
+ import { readFileSync, 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
9
  import { QwenBrowser } from './qwen-browser.js';
10
- import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, PROVIDER_IDS, } from './providers.js';
10
+ import { GlmBrowser } from './glm-browser.js';
11
+ import { authMarkerHas } from './browser-pure.js';
12
+ import { canShowWindow } from './browser-login.js';
13
+ import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, providerHasModelPicker, providerNeedsVisibleBrowser, providerConfigSection, PROVIDER_IDS, } from './providers.js';
11
14
  import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
12
15
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
13
16
  import { runAgentLoop } from './agent-loop.js';
@@ -30,10 +33,11 @@ import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
30
33
  import { performCompact } from './compact.js';
31
34
  import { clampGoalCondition } from './goal.js';
32
35
  import { applyBrowserLiveConfig, applyStoreLiveConfig } from './config-live.js';
33
- import { buildHandoffText, saveProviderHandoff, readProviderHandoff, clearProviderHandoff, } from './provider-handoff.js';
36
+ import { buildHandoffText, saveProviderHandoff, readProviderHandoff, handoffFromLabel, clearProviderHandoff, } from './provider-handoff.js';
34
37
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
35
38
  import { createCliArgs } from './cli-args.js';
36
39
  import { qwenContextWindow } from './qwen-ui.js';
40
+ import { glmContextWindow } from './glm-ui.js';
37
41
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
38
42
  import { contentWidth } from './width.js';
39
43
  import { renderStartupBanner } from './banner.js';
@@ -53,11 +57,13 @@ let lastAssistantMessage = '';
53
57
  // cannot do anything useful and would just hammer the rate limit, so we refuse
54
58
  // it. It does NOT touch the throttle itself.
55
59
  const MIN_LOOP_MS = 15_000;
56
- // True when a DeepSeek session/credentials were stored earlier (auth.json).
57
- // Used by /doctor to report that auto re-login is ready.
58
- function authMarkerExists() {
60
+ // True when a session/credentials for `provider` were stored earlier
61
+ // (auth.json). Used by /doctor and the startup banner to report that auto
62
+ // re-login is ready. The marker is PER PROVIDER (see authMarkerHas).
63
+ function authMarkerExists(provider = config.provider) {
59
64
  try {
60
- return existsSync(path.join(ZAMES_HOME, 'auth.json'));
65
+ const raw = readFileSync(path.join(ZAMES_HOME, 'auth.json'), 'utf-8');
66
+ return authMarkerHas(raw, provider);
61
67
  }
62
68
  catch {
63
69
  return false;
@@ -76,7 +82,16 @@ const { getArg, hasFlag, getPositional } = createCliArgs(process.argv.slice(2));
76
82
  // compaction).
77
83
  const COMPACT_FALLBACK_LIMIT = 40;
78
84
  const config = loadConfig();
79
- // A `--provider <deepseek|qwen>` CLI flag selects the backend for THIS run,
85
+ /**
86
+ * Keep the legacy `qwen.enabled` mirror in sync with the ACTIVE provider. The
87
+ * field is written in one place (it used to be set from three call sites) and
88
+ * is no longer READ by the code — it survives only so an old config keeps its
89
+ * meaning. PURE state change on the live config object.
90
+ */
91
+ function setProviderEnabledFlag(provider) {
92
+ config.qwen.enabled = provider === 'qwen';
93
+ }
94
+ // A `--provider <id>` CLI flag selects the backend for THIS run,
80
95
  // mirroring the /provider command (which changes it live). Without this an
81
96
  // operator who typed `--provider qwen` got the DEFAULT backend — the flag was
82
97
  // silently treated as free-form task text and DeepSeek opened instead of Qwen.
@@ -84,7 +99,7 @@ const providerArg = getArg('--provider', null);
84
99
  if (providerArg !== null) {
85
100
  if (isProviderId(providerArg)) {
86
101
  config.provider = providerArg;
87
- config.qwen.enabled = providerArg === 'qwen';
102
+ setProviderEnabledFlag(providerArg);
88
103
  }
89
104
  else {
90
105
  console.error(theme.warn('Unknown --provider "' +
@@ -107,9 +122,17 @@ let currentLocale = isLocale(config.ui?.locale)
107
122
  const t = (key, params) => translate(currentLocale)(key, params);
108
123
  // Headless is the default. --headed forces a visible window (useful for the
109
124
  // first sign-in or debugging the DOM). --headless keeps it explicit.
110
- const headless = hasFlag('--headed')
111
- ? false
112
- : hasFlag('--headless') || config.headless;
125
+ // DeepSeek/Qwen run headless by default; GLM CANNOT: z.ai answers a headless
126
+ // send with an Aliyun captcha (see PROVIDERS[id].needsVisibleBrowser). An
127
+ // explicit --headless still wins, and --headed forces a window everywhere.
128
+ function headlessFor(provider) {
129
+ if (hasFlag('--headed'))
130
+ return false;
131
+ if (hasFlag('--headless'))
132
+ return true;
133
+ return config.headless && !providerNeedsVisibleBrowser(provider);
134
+ }
135
+ const headless = headlessFor(config.provider);
113
136
  const debug = hasFlag('--debug') || config.debug;
114
137
  const maxIterArg = getArg('--max-iter', null);
115
138
  // A non-numeric `--max-iter abc` yields NaN, which the loop treats as
@@ -314,6 +337,23 @@ await reloadModules();
314
337
  // Enabled by the --dev flag (`npm run dev` sets it) or config.hotReload === true.
315
338
  // In normal mode (npm start, global zames) auto-reload is off.
316
339
  const devMode = hasFlag('--dev') || config.hotReload === true;
340
+ // The saved credentials of the ACTIVE provider: DeepSeek keeps the legacy
341
+ // `browser.auth` (so an old config works), every other backend owns its own
342
+ // section, so a DeepSeek login is never typed into a Qwen/GLM form.
343
+ function activeAuth() {
344
+ const section = providerConfigSection(config.provider);
345
+ if (!section) {
346
+ return {
347
+ username: config.browser.auth.username,
348
+ password: config.browser.auth.password,
349
+ };
350
+ }
351
+ const sec = config[section];
352
+ return {
353
+ username: (sec && sec.auth && sec.auth.username) || '',
354
+ password: (sec && sec.auth && sec.auth.password) || '',
355
+ };
356
+ }
317
357
  // Safe auto-reload: on a load error we keep the previous working modules.
318
358
  async function autoReload() {
319
359
  if (!devMode)
@@ -485,7 +525,9 @@ ${theme.bold(t('help.sec.context'))}
485
525
  ${t('help.cmd.lang')}
486
526
 
487
527
  ${theme.bold(t('help.sec.provider'))}
488
- ${t('help.cmd.provider')}${config.provider === 'qwen' ? '\n ' + t('help.cmd.model') : ''}
528
+ ${t('help.cmd.provider')}${providerHasModelPicker(config.provider)
529
+ ? '\n ' + t('help.cmd.model')
530
+ : ''}
489
531
 
490
532
  ${theme.bold(t('help.sec.files'))}
491
533
  ${t('help.cmd.transcript')}
@@ -495,6 +537,7 @@ ${theme.bold(t('help.sec.files'))}
495
537
  ${t('help.cmd.rewind_list')}
496
538
  ${t('help.cmd.export')}
497
539
  ${t('help.cmd.cost')}
540
+ ${t('help.cmd.limits')}
498
541
  ${t('help.cmd.copy')}
499
542
  ${t('help.cmd.doctor')}
500
543
  ${t('help.cmd.add_dir')}
@@ -660,90 +703,171 @@ async function resolveWorkdir() {
660
703
  * config.browser.answerTimeoutMs there cut them off; pick the active
661
704
  * provider's own value.
662
705
  */
706
+ /** True when the ACTIVE provider derives its pause adaptively (see part C). */
707
+ function providerAdaptiveThrottle(provider) {
708
+ const section = providerConfigSection(provider);
709
+ if (!section)
710
+ return false;
711
+ const sec = config[section];
712
+ return !!sec && sec.adaptiveThrottle === true;
713
+ }
714
+ /** The configured model id of a provider ('n/a' when it has no model picker). */
715
+ function providerModelOf(provider) {
716
+ const section = providerConfigSection(provider);
717
+ if (!section)
718
+ return 'n/a';
719
+ const sec = config[section];
720
+ const m = sec ? sec.model : '';
721
+ return typeof m === 'string' ? m : '';
722
+ }
663
723
  function providerAnswerTimeoutMs() {
664
- return config.provider === 'qwen'
665
- ? config.qwen.answerTimeoutMs
666
- : config.browser.answerTimeoutMs;
724
+ return providerSectionNumber('answerTimeoutMs', config.browser.answerTimeoutMs);
725
+ }
726
+ /**
727
+ * Read a number from the ACTIVE provider's own config section (`qwen.*`,
728
+ * `glm.*`), or the shared fallback for DeepSeek (whose section is `browser`).
729
+ * This replaced `provider === 'qwen' ? config.qwen.x : shared`, which silently
730
+ * gave every NEW provider the DeepSeek timing.
731
+ */
732
+ function providerSectionNumber(field, fallback) {
733
+ const section = providerConfigSection(config.provider);
734
+ if (!section)
735
+ return fallback;
736
+ const s = config[section];
737
+ const v = s ? s[field] : undefined;
738
+ return typeof v === 'number' ? v : fallback;
739
+ }
740
+ /** The per-provider config section object, or null for DeepSeek. */
741
+ function activeProviderSection() {
742
+ const section = providerConfigSection(config.provider);
743
+ if (!section)
744
+ return null;
745
+ return (config[section] ||
746
+ null);
667
747
  }
668
748
  /** The provider's base chat URL, used by /chat to build the right link. */
669
749
  function browserProviderUrl() {
670
750
  return providerChatUrlPrefix(config.provider);
671
751
  }
672
752
  /**
673
- * The context window (tokens) for the ACTIVE provider. The two backends
674
- * advertise different windows and Qwen's is PER-MODEL, so a single global
675
- * number would either overstate Qwen's (258K models) or understate the 1M
676
- * builds — and the number is the denominator of the auto-compact threshold,
753
+ * The context window (tokens) for the ACTIVE provider. The backends advertise
754
+ * different windows and a provider's window may be PER-MODEL (Qwen), so a
755
+ * single global number would either overstate a small model or understate a
756
+ * 1M build — and the number is the denominator of the auto-compact threshold,
677
757
  * so an error directly delays (or prematurely fires) compaction.
678
758
  *
679
- * `config.qwen.contextLimit` / `ui.contextLimit` are the operator-tunable
680
- * values; `/model` rewrites the Qwen one from the picked model id.
759
+ * The provider's own `contextLimit` wins; DeepSeek (which has no section)
760
+ * falls back to `ui.contextLimit`.
681
761
  */
682
762
  function effectiveContextLimit() {
683
- if (config.provider === 'qwen') {
684
- return config.qwen.contextLimit > 0
685
- ? config.qwen.contextLimit
686
- : config.ui.contextLimit;
687
- }
763
+ const own = providerSectionNumber('contextLimit', 0);
764
+ if (own > 0)
765
+ return own;
688
766
  return config.ui.contextLimit;
689
767
  }
690
768
  /**
691
- * Re-derive `qwen.contextLimit` from a picked model id and persist it. Called
692
- * by `/model` on an explicit pick (never on startup), so a manual
693
- * `/config set qwen.contextLimit` survives until the operator switches model
694
- * again. An UNKNOWN model id leaves the limit alone: guessing would either
695
- * fire auto-compact too early or too late. The live editor is repainted by
696
- * the caller (it owns the `editor` reference); this only touches config.
769
+ * Re-derive the ACTIVE provider's `contextLimit` from a picked model id and
770
+ * persist it. Called by `/model` on an explicit pick (never on startup), so a
771
+ * manual `/config set <provider>.contextLimit` survives until the operator
772
+ * switches model again. An UNKNOWN model id leaves the limit alone: guessing
773
+ * would either fire auto-compact too early or too late. The live editor is
774
+ * repainted by the caller (it owns the `editor` reference); this only touches
775
+ * config.
697
776
  */
698
777
  function applyModelContextLimit(model) {
699
- const window = qwenContextWindow(model);
778
+ const section = providerConfigSection(config.provider);
779
+ if (!section)
780
+ return;
781
+ const window = config.provider === 'qwen' ? qwenContextWindow(model) : glmContextWindow(model);
700
782
  if (window === null)
701
783
  return;
702
- config.qwen.contextLimit = window;
784
+ const sec = activeProviderSection();
785
+ if (!sec)
786
+ return;
787
+ sec.contextLimit = window;
703
788
  try {
704
- writeConfigValue('home', 'qwen.contextLimit', String(window));
789
+ writeConfigValue('home', `${section}.contextLimit`, String(window));
705
790
  }
706
791
  catch { }
707
792
  }
708
793
  /**
709
794
  * Build the browser for a provider. Called at startup AND on a live
710
795
  * `/provider` switch, so both paths construct the class the same way. The
711
- * two backends differ in every layer (selectors, SSE format, chat-id scheme),
712
- * so the choice is a class, not a flag; the shared timings live in
713
- * config.browser and only Qwen-only knobs are overridden.
796
+ * backends differ in every layer (selectors, SSE format, chat-id scheme), so
797
+ * the choice is a class, not a flag; the shared timings live in
798
+ * config.browser and only the provider's own section is overridden.
714
799
  */
715
800
  function createProviderBrowser(provider) {
801
+ // Saved credentials go into the ACTIVE provider's section: browser.auth
802
+ // belongs to DeepSeek (so old configs keep working), qwen.auth / glm.auth
803
+ // are their own. Sharing one pair meant a DeepSeek login was typed into the
804
+ // Qwen form.
716
805
  const persistAuth = (username, password) => {
717
- config.browser.auth.username = username;
718
- config.browser.auth.password = password;
806
+ const section = providerConfigSection(provider);
807
+ const prefix = section ? section + '.auth' : 'browser.auth';
808
+ const target = section
809
+ ? config[section]
810
+ : null;
811
+ if (target) {
812
+ if (!target.auth)
813
+ target.auth = { username: '', password: '' };
814
+ target.auth.username = username;
815
+ target.auth.password = password;
816
+ }
817
+ else {
818
+ config.browser.auth.username = username;
819
+ config.browser.auth.password = password;
820
+ }
719
821
  try {
720
- writeConfigValue('home', 'browser.auth.username', username);
721
- writeConfigValue('home', 'browser.auth.password', password);
822
+ writeConfigValue('home', prefix + '.username', username);
823
+ writeConfigValue('home', prefix + '.password', password);
722
824
  }
723
825
  catch { }
724
826
  };
725
- return provider === 'qwen'
726
- ? new QwenBrowser({
727
- headless,
728
- debug,
729
- channel: config.browserChannel,
730
- ...config.browser,
731
- // Qwen-specific overrides (see config.qwen): no 15s DeepSeek
732
- // throttle, a longer cold-start answer timeout.
733
- minSendIntervalMs: config.qwen.minSendIntervalMs,
734
- answerTimeoutMs: config.qwen.answerTimeoutMs,
735
- model: config.qwen.model,
736
- locale: currentLocale,
737
- onAuthSave: persistAuth,
738
- })
739
- : new DeepSeekBrowser({
740
- headless,
741
- debug,
742
- channel: config.browserChannel,
743
- ...config.browser,
744
- locale: currentLocale,
745
- onAuthSave: persistAuth,
746
- });
827
+ switch (provider) {
828
+ case 'qwen':
829
+ return new QwenBrowser({
830
+ headless: headlessFor('qwen'),
831
+ debug,
832
+ channel: config.browserChannel,
833
+ ...config.browser,
834
+ // Qwen-specific overrides (see config.qwen): no 15s DeepSeek
835
+ // throttle, a longer cold-start answer timeout.
836
+ minSendIntervalMs: config.qwen.minSendIntervalMs,
837
+ answerTimeoutMs: config.qwen.answerTimeoutMs,
838
+ model: config.qwen.model,
839
+ auth: config.qwen.auth,
840
+ loginTimeoutMs: config.browser.loginTimeoutMs,
841
+ locale: currentLocale,
842
+ onAuthSave: persistAuth,
843
+ });
844
+ case 'glm':
845
+ return new GlmBrowser({
846
+ headless: headlessFor('glm'),
847
+ debug,
848
+ channel: config.browserChannel,
849
+ ...config.browser,
850
+ minSendIntervalMs: config.glm.minSendIntervalMs,
851
+ answerTimeoutMs: config.glm.answerTimeoutMs,
852
+ model: config.glm.model,
853
+ auth: config.glm.auth,
854
+ sendTelemetry: config.browser.sendTelemetry,
855
+ logsDir: config.transcript.dir,
856
+ loginTimeoutMs: config.browser.loginTimeoutMs,
857
+ locale: currentLocale,
858
+ onAuthSave: persistAuth,
859
+ });
860
+ case 'deepseek':
861
+ default:
862
+ return new DeepSeekBrowser({
863
+ headless: headlessFor('deepseek'),
864
+ debug,
865
+ channel: config.browserChannel,
866
+ ...config.browser,
867
+ locale: currentLocale,
868
+ onAuthSave: persistAuth,
869
+ });
870
+ }
747
871
  }
748
872
  /**
749
873
  * Print the dialogue of the just-opened chat into the terminal. Called by
@@ -957,10 +1081,16 @@ async function main() {
957
1081
  // never block startup.
958
1082
  const warnUiHealth = (h) => {
959
1083
  if (h.missingCritical.length) {
960
- console.error(theme.warn(t('ui.probe_failed', { v: h.missingCritical.join(', ') })));
1084
+ console.error(theme.warn(t('ui.probe_failed', {
1085
+ v: h.missingCritical.join(', '),
1086
+ provider: providerLabel(config.provider),
1087
+ })));
961
1088
  }
962
1089
  else if (h.missing.length) {
963
- console.error(theme.warn(t('ui.probe_degraded', { v: h.missing.join(', ') })));
1090
+ console.error(theme.warn(t('ui.probe_degraded', {
1091
+ v: h.missing.join(', '),
1092
+ provider: providerLabel(config.provider),
1093
+ })));
964
1094
  }
965
1095
  };
966
1096
  try {
@@ -1021,14 +1151,20 @@ async function main() {
1021
1151
  await transcript.close();
1022
1152
  return;
1023
1153
  }
1154
+ if (providerNeedsVisibleBrowser(config.provider) && headlessFor(config.provider)) {
1155
+ console.log(theme.warn(t('provider.needs_headed', { v: providerLabel(config.provider) })));
1156
+ }
1024
1157
  console.log(theme.system(t('msg.interactive')));
1025
1158
  console.log(theme.system(t('msg.queue_hint')));
1026
1159
  console.log(theme.dim(t('msg.first_hint')));
1027
- // No stored session yet (auth.json absent): DeepSeek will ask for a sign-in.
1028
- // The auto-login handles it, but a heads-up in the banner saves the operator
1029
- // from wondering why the first task pauses on a login form.
1160
+ // No stored session yet (auth.json absent for THIS provider): the first
1161
+ // task will need a sign-in. The auto-login handles it, but a heads-up in
1162
+ // the banner saves the operator from wondering why the first task pauses.
1163
+ // The hint text describes the browser-based flow (see src/browser-login.ts);
1164
+ // the old wording promised a terminal prompt, which is no longer the main
1165
+ // path.
1030
1166
  if (!authMarkerExists()) {
1031
- console.log(theme.warn(t('msg.first_login_hint')));
1167
+ console.log(theme.warn(t('msg.first_login_hint', { provider: providerLabel(config.provider) })));
1032
1168
  }
1033
1169
  // Nudge when BACKLOG.md has grown large: /improve reads the whole file, so a
1034
1170
  // bloated backlog costs tokens on every self-improvement run. Best-effort.
@@ -1390,8 +1526,10 @@ async function main() {
1390
1526
  const { runLoginForm } = await import('./tui/run-login.js');
1391
1527
  return await runLoginForm({
1392
1528
  t,
1393
- initialUsername: config.browser.auth.username,
1394
- initialPassword: config.browser.auth.password,
1529
+ // The SAVED credentials of the ACTIVE provider (DeepSeek keeps
1530
+ // browser.auth; Qwen/GLM own their section).
1531
+ initialUsername: activeAuth().username,
1532
+ initialPassword: activeAuth().password,
1395
1533
  note: info.hasSavedPassword || info.savedUsername
1396
1534
  ? t('auth.form.note_saved')
1397
1535
  : undefined,
@@ -1737,10 +1875,11 @@ async function main() {
1737
1875
  }
1738
1876
  // The context window size feeds the status-bar percentage and its color.
1739
1877
  // Apply it to the live editor immediately (the value is a plain number,
1740
- // no restart needed). A change to EITHER provider's limit matters: the
1741
- // Qwen one is what a /model pick rewrites, and the active provider is
1742
- // what decides which value the editor shows.
1743
- if ((path === 'ui.contextLimit' || path === 'qwen.contextLimit') &&
1878
+ // no restart needed). ANY provider's limit matters: a /model pick rewrites
1879
+ // the active provider's own value, and a manual /config set on a non-active
1880
+ // provider must not leave the editor showing the old denominator when the
1881
+ // operator switches to it.
1882
+ if ((path === 'ui.contextLimit' || /\.contextLimit$/.test(path)) &&
1744
1883
  typeof value === 'number') {
1745
1884
  if (editor) {
1746
1885
  editor.setContextLimit(effectiveContextLimit());
@@ -1751,9 +1890,16 @@ async function main() {
1751
1890
  // by the browser at construction, so push them onto the live instance.
1752
1891
  // (deepThinking/webSearch are handled above because they also repaint the
1753
1892
  // status icons.)
1893
+ //
1894
+ // `browser.*` knobs belong to DEEPSEEK. Each other backend copies its
1895
+ // timings from its OWN section, so applying a `browser.*` value to the
1896
+ // live Qwen/GLM instance would clobber its interval with DeepSeek's
1897
+ // 15s (the real bug: /config set browser.minSendIntervalMs rewrote the
1898
+ // Qwen send interval).
1754
1899
  if (path.startsWith('browser.')) {
1755
1900
  const b = browser;
1756
- if (applyBrowserLiveConfig(path, value, b)) {
1901
+ if (config.provider === 'deepseek' &&
1902
+ applyBrowserLiveConfig(path, value, b)) {
1757
1903
  // minSendIntervalMs feeds the send countdown the UI renders.
1758
1904
  if (editor)
1759
1905
  editor.refreshStatus();
@@ -1764,15 +1910,15 @@ async function main() {
1764
1910
  undo: undo,
1765
1911
  checkpoints: checkpoints,
1766
1912
  });
1767
- // Qwen timing knobs feed the QwenBrowser constructor. Its LIVE fields use
1768
- // the SAME names, so push them there when the active backend is Qwen (a
1769
- // no-op on DeepSeek, which has no qwen-only knobs).
1770
- if (path === 'qwen.minSendIntervalMs' || path === 'qwen.answerTimeoutMs') {
1913
+ // A provider's OWN timing knobs feed its browser constructor. The LIVE
1914
+ // fields use the SAME names, so push them there when that backend is
1915
+ // active (a no-op otherwise). Generalized from the two hardcoded qwen.*
1916
+ // paths so a new backend needs no new branch here.
1917
+ const timingMatch = path.match(/^(qwen|glm)\.(minSendIntervalMs|answerTimeoutMs)$/);
1918
+ if (timingMatch) {
1771
1919
  const b = browser;
1772
- if (config.provider === 'qwen') {
1773
- const field = path === 'qwen.minSendIntervalMs'
1774
- ? 'minSendIntervalMs'
1775
- : 'answerTimeoutMs';
1920
+ if (config.provider === timingMatch[1]) {
1921
+ const field = timingMatch[2];
1776
1922
  if (field in b) {
1777
1923
  b[field] = value;
1778
1924
  if (editor)
@@ -1810,6 +1956,20 @@ async function main() {
1810
1956
  // The DeepSeek toggles themselves are clicked on the next send by
1811
1957
  // _applyToggles(), so this is safe even while a generation is in flight.
1812
1958
  function applyLiveToggle(cmd) {
1959
+ // GLM's flagship models (x-preview-l, glm-5.3, glm-4.6v) report
1960
+ // skip_think=false in the catalogue: thinking CANNOT be turned off, and
1961
+ // the site always sends enable_thinking=true. Flipping the switch would
1962
+ // only mislead the operator (and the icon would lie), so it is refused.
1963
+ if (cmd.kind === 'thinking' &&
1964
+ config.provider === 'glm' &&
1965
+ cmd.mode !== 'on') {
1966
+ const line = t('toggle.thinking_locked');
1967
+ if (editor)
1968
+ editor.printAbove(theme.warn(line));
1969
+ else
1970
+ console.log(theme.warn(line));
1971
+ return;
1972
+ }
1813
1973
  const b = browser;
1814
1974
  const cur = cmd.kind === 'thinking' ? b.deepThinking : b.webSearch;
1815
1975
  const next = cmd.mode === 'toggle' ? !cur : cmd.mode === 'on';
@@ -2404,7 +2564,7 @@ async function main() {
2404
2564
  if (lower === '/provider' || lower.startsWith('/provider ')) {
2405
2565
  const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2406
2566
  if (!arg || arg === 'status') {
2407
- const model = config.provider === 'qwen' ? config.qwen.model || '(default)' : 'n/a';
2567
+ const model = providerModelOf(config.provider) || '(default)';
2408
2568
  console.log(theme.system(t('provider.status', {
2409
2569
  v: config.provider,
2410
2570
  m: model,
@@ -2483,6 +2643,7 @@ async function main() {
2483
2643
  if (summary) {
2484
2644
  saveProviderHandoff({
2485
2645
  provider: arg,
2646
+ from,
2486
2647
  workdir: currentWorkdir,
2487
2648
  summary,
2488
2649
  task: lastTaskText || undefined,
@@ -2504,7 +2665,7 @@ async function main() {
2504
2665
  saveLastChat(currentChatId, currentWorkdir);
2505
2666
  // Persist the choice FIRST so a crash mid-switch still remembers it.
2506
2667
  config.provider = arg;
2507
- config.qwen.enabled = arg === 'qwen';
2668
+ setProviderEnabledFlag(arg);
2508
2669
  try {
2509
2670
  writeConfigValue('home', 'provider', arg);
2510
2671
  }
@@ -2535,6 +2696,9 @@ async function main() {
2535
2696
  currentChatId = await browser.getCurrentChatId().catch(() => null);
2536
2697
  if (editor) {
2537
2698
  editor.setProvider(providerLabel(arg));
2699
+ // The context window is provider-specific: without this the status
2700
+ // bar kept showing the OLD backend's denominator after a switch.
2701
+ editor.setContextLimit(effectiveContextLimit());
2538
2702
  }
2539
2703
  try {
2540
2704
  warnUiHealth(await browser.probeUiHealth());
@@ -2546,7 +2710,7 @@ async function main() {
2546
2710
  // The new backend did not come up: keep the OLD one active so the
2547
2711
  // session keeps working, and roll the persisted choice back.
2548
2712
  config.provider = from;
2549
- config.qwen.enabled = from === 'qwen';
2713
+ setProviderEnabledFlag(from);
2550
2714
  try {
2551
2715
  writeConfigValue('home', 'provider', from);
2552
2716
  }
@@ -2562,23 +2726,28 @@ async function main() {
2562
2726
  }
2563
2727
  continue;
2564
2728
  }
2565
- console.error(theme.warn(t('provider.usage')));
2729
+ console.error(theme.warn(t('provider.usage', { providers: PROVIDER_IDS.join('|') })));
2566
2730
  continue;
2567
2731
  }
2568
2732
  if (lower === '/model' || lower.startsWith('/model ')) {
2569
2733
  const arg = trimmed.slice('/model'.length).trim();
2570
- if (config.provider !== 'qwen') {
2571
- // `/model` is Qwen-only: it picks a model in Qwen's header dropdown.
2572
- // DeepSeek exposes no model selection from zames, so the command is
2573
- // hidden from the hints and only explains itself when typed by hand.
2574
- console.log(theme.system(t('qwen.model_deepseek_only')));
2734
+ // `/model` is available to ANY provider that exposes a model picker
2735
+ // (Qwen, GLM). DeepSeek has none, so the command is hidden from the
2736
+ // hints there and only explains itself when typed by hand.
2737
+ if (!providerHasModelPicker(config.provider)) {
2738
+ console.log(theme.system(t('model.unsupported', { provider: providerLabel(config.provider) })));
2575
2739
  continue;
2576
2740
  }
2741
+ const section = providerConfigSection(config.provider);
2742
+ const sectionObj = activeProviderSection();
2743
+ if (!section || !sectionObj)
2744
+ continue;
2745
+ const curModel = String(sectionObj.model || '');
2577
2746
  const qb = browser;
2578
2747
  if (!arg) {
2579
2748
  const models = qb.listModels ? await qb.listModels() : [];
2580
2749
  if (!models.length) {
2581
- console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2750
+ console.log(theme.system(t('model.list', { v: '(none)' })));
2582
2751
  continue;
2583
2752
  }
2584
2753
  // In the TUI, open a proper SELECT LIST instead of dumping text.
@@ -2589,12 +2758,14 @@ async function main() {
2589
2758
  try {
2590
2759
  const { selectListInk } = await import('./tui/run-select.js');
2591
2760
  picked = await selectListInk({
2592
- title: t('qwen.model_pick_title'),
2593
- initial: config.qwen.model || models[0].id,
2761
+ title: t('model.pick_title', {
2762
+ provider: providerLabel(config.provider),
2763
+ }),
2764
+ initial: curModel || models[0].id,
2594
2765
  items: models.map((m) => ({
2595
2766
  value: m.id,
2596
2767
  label: m.name || m.id,
2597
- detail: m.id === config.qwen.model ? t('tui.list.current') : m.id,
2768
+ detail: m.id === curModel ? t('tui.list.current') : m.id,
2598
2769
  })),
2599
2770
  locale: currentLocale,
2600
2771
  });
@@ -2607,32 +2778,35 @@ async function main() {
2607
2778
  editor.resume();
2608
2779
  }
2609
2780
  if (picked) {
2610
- config.qwen.model = picked;
2781
+ sectionObj.model = picked;
2611
2782
  if (typeof qb.model === 'string')
2612
2783
  qb.model = picked;
2613
- // Derive the context window from the picked model: Qwen windows
2614
- // are per-model (258K vs 1M), and this number is the denominator
2615
- // of the auto-compact threshold. The operator's manual
2616
- // `/config set qwen.contextLimit` still wins afterwards (this
2617
- // only runs on an explicit pick).
2784
+ // Derive the context window from the picked model: windows are
2785
+ // per-model, and this number is the denominator of the
2786
+ // auto-compact threshold. The operator's manual
2787
+ // `/config set <provider>.contextLimit` still wins afterwards
2788
+ // (this only runs on an explicit pick).
2618
2789
  applyModelContextLimit(picked);
2619
2790
  if (editor) {
2620
2791
  editor.setContextLimit(effectiveContextLimit());
2621
2792
  editor.refreshStatus();
2622
2793
  }
2623
2794
  try {
2624
- writeConfigValue('home', 'qwen.model', picked);
2795
+ writeConfigValue('home', section + '.model', picked);
2625
2796
  }
2626
2797
  catch { }
2627
- console.log(theme.system(t('qwen.model_set', { v: picked })));
2798
+ console.log(theme.system(t('model.set', {
2799
+ provider: providerLabel(config.provider),
2800
+ v: picked,
2801
+ })));
2628
2802
  }
2629
2803
  continue;
2630
2804
  }
2631
2805
  const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2632
- console.log(theme.system(t('qwen.model_list', { v: lines })));
2806
+ console.log(theme.system(t('model.list', { v: lines })));
2633
2807
  continue;
2634
2808
  }
2635
- config.qwen.model = arg;
2809
+ sectionObj.model = arg;
2636
2810
  if (typeof qb.model === 'string')
2637
2811
  qb.model = arg;
2638
2812
  applyModelContextLimit(arg);
@@ -2641,10 +2815,10 @@ async function main() {
2641
2815
  editor.refreshStatus();
2642
2816
  }
2643
2817
  try {
2644
- writeConfigValue('home', 'qwen.model', arg);
2818
+ writeConfigValue('home', section + '.model', arg);
2645
2819
  }
2646
2820
  catch { }
2647
- console.log(theme.system(t('qwen.model_set', { v: arg })));
2821
+ console.log(theme.system(t('model.set', { provider: providerLabel(config.provider), v: arg })));
2648
2822
  continue;
2649
2823
  }
2650
2824
  if (lower === '/chat') {
@@ -3328,6 +3502,37 @@ async function main() {
3328
3502
  console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3329
3503
  continue;
3330
3504
  }
3505
+ if (lower === '/limits') {
3506
+ const { readSendEvents, summarizeSends, sendLogPath } = await import('./send-telemetry.js');
3507
+ const logsDir = config.transcript.dir;
3508
+ const events = readSendEvents(logsDir, config.provider);
3509
+ if (!events.length) {
3510
+ console.log(theme.system(t('limits.none', { path: sendLogPath(logsDir, config.provider) })));
3511
+ }
3512
+ else {
3513
+ const st = summarizeSends(events);
3514
+ console.log(theme.system(t('limits.title', { provider: providerLabel(config.provider) })));
3515
+ for (const [label, n] of [
3516
+ ['10m', st.last10m],
3517
+ ['30m', st.last30m],
3518
+ ['60m', st.last60m],
3519
+ ]) {
3520
+ console.log(' ' + t('limits.window', { label, n: String(n) }));
3521
+ }
3522
+ console.log(' ' +
3523
+ t('limits.rate', {
3524
+ n: String(st.rateLimits),
3525
+ sec: String(Math.round(st.avgBlockMs / 1000)),
3526
+ }));
3527
+ const adaptive = providerAdaptiveThrottle(config.provider);
3528
+ console.log(' ' +
3529
+ t('limits.interval', {
3530
+ sec: String(Math.round(browser.sendIntervalMs() / 1000)),
3531
+ adaptive: adaptive ? t('limits.adaptive_on') : '',
3532
+ }));
3533
+ }
3534
+ continue;
3535
+ }
3331
3536
  if (lower === '/cost' || lower === '/usage') {
3332
3537
  let stats = mod.commands.summarizeTranscript([]);
3333
3538
  if (transcript.file) {
@@ -3413,7 +3618,11 @@ async function main() {
3413
3618
  hasOrigin: gitOk
3414
3619
  ? (await runGitArgs(['remote'], currentWorkdir, 5000)).includes('origin')
3415
3620
  : undefined,
3416
- minSendIntervalMs: config.browser.minSendIntervalMs,
3621
+ // The ACTIVE browser's own interval: a per-provider timing
3622
+ // (Qwen 4s, GLM 0) used to be reported as DeepSeek's 15s.
3623
+ minSendIntervalMs: browser.sendIntervalMs(),
3624
+ adaptiveThrottle: providerAdaptiveThrottle(config.provider),
3625
+ displayAvailable: canShowWindow(process.env, process.platform),
3417
3626
  sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
3418
3627
  headless: !!headless,
3419
3628
  headlessUaCached: await headlessUACacheReady().catch(() => false),
@@ -3421,7 +3630,9 @@ async function main() {
3421
3630
  uiMissing: uiHealth?.missing,
3422
3631
  uiMissingCritical: uiHealth?.missingCritical,
3423
3632
  provider: config.provider,
3424
- providerModel: config.provider === 'qwen' ? config.qwen.model : undefined,
3633
+ providerModel: providerConfigSection(config.provider)
3634
+ ? providerModelOf(config.provider)
3635
+ : undefined,
3425
3636
  }, t)));
3426
3637
  continue;
3427
3638
  }
@@ -3828,7 +4039,7 @@ async function main() {
3828
4039
  if (handoff) {
3829
4040
  finalTaskText =
3830
4041
  buildHandoffText({
3831
- from: handoff.provider === 'qwen' ? 'DeepSeek' : 'Qwen',
4042
+ from: handoffFromLabel(handoff),
3832
4043
  to: providerLabel(config.provider),
3833
4044
  summary: handoff.summary,
3834
4045
  task: handoff.task,