zames_pro 2.80.8 → 2.81.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,7 @@ ${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) ? '\n ' + t('help.cmd.model') : ''}
489
529
 
490
530
  ${theme.bold(t('help.sec.files'))}
491
531
  ${t('help.cmd.transcript')}
@@ -495,6 +535,7 @@ ${theme.bold(t('help.sec.files'))}
495
535
  ${t('help.cmd.rewind_list')}
496
536
  ${t('help.cmd.export')}
497
537
  ${t('help.cmd.cost')}
538
+ ${t('help.cmd.limits')}
498
539
  ${t('help.cmd.copy')}
499
540
  ${t('help.cmd.doctor')}
500
541
  ${t('help.cmd.add_dir')}
@@ -660,90 +701,177 @@ async function resolveWorkdir() {
660
701
  * config.browser.answerTimeoutMs there cut them off; pick the active
661
702
  * provider's own value.
662
703
  */
704
+ /** True when the ACTIVE provider derives its pause adaptively (see part C). */
705
+ function providerAdaptiveThrottle(provider) {
706
+ const section = providerConfigSection(provider);
707
+ if (!section)
708
+ return false;
709
+ const sec = config[section];
710
+ return !!sec && sec.adaptiveThrottle === true;
711
+ }
712
+ /** The configured model id of a provider ('n/a' when it has no model picker). */
713
+ function providerModelOf(provider) {
714
+ const section = providerConfigSection(provider);
715
+ if (!section)
716
+ return 'n/a';
717
+ const sec = config[section];
718
+ const m = sec ? sec.model : '';
719
+ return typeof m === 'string' ? m : '';
720
+ }
663
721
  function providerAnswerTimeoutMs() {
664
- return config.provider === 'qwen'
665
- ? config.qwen.answerTimeoutMs
666
- : config.browser.answerTimeoutMs;
722
+ return providerSectionNumber('answerTimeoutMs', config.browser.answerTimeoutMs);
723
+ }
724
+ /**
725
+ * Read a number from the ACTIVE provider's own config section (`qwen.*`,
726
+ * `glm.*`), or the shared fallback for DeepSeek (whose section is `browser`).
727
+ * This replaced `provider === 'qwen' ? config.qwen.x : shared`, which silently
728
+ * gave every NEW provider the DeepSeek timing.
729
+ */
730
+ function providerSectionNumber(field, fallback) {
731
+ const section = providerConfigSection(config.provider);
732
+ if (!section)
733
+ return fallback;
734
+ const s = config[section];
735
+ const v = s ? s[field] : undefined;
736
+ return typeof v === 'number' ? v : fallback;
737
+ }
738
+ /** The per-provider config section object, or null for DeepSeek. */
739
+ function activeProviderSection() {
740
+ const section = providerConfigSection(config.provider);
741
+ if (!section)
742
+ return null;
743
+ return (config[section] ||
744
+ null);
667
745
  }
668
746
  /** The provider's base chat URL, used by /chat to build the right link. */
669
747
  function browserProviderUrl() {
670
748
  return providerChatUrlPrefix(config.provider);
671
749
  }
672
750
  /**
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,
751
+ * The context window (tokens) for the ACTIVE provider. The backends advertise
752
+ * different windows and a provider's window may be PER-MODEL (Qwen), so a
753
+ * single global number would either overstate a small model or understate a
754
+ * 1M build — and the number is the denominator of the auto-compact threshold,
677
755
  * so an error directly delays (or prematurely fires) compaction.
678
756
  *
679
- * `config.qwen.contextLimit` / `ui.contextLimit` are the operator-tunable
680
- * values; `/model` rewrites the Qwen one from the picked model id.
757
+ * The provider's own `contextLimit` wins; DeepSeek (which has no section)
758
+ * falls back to `ui.contextLimit`.
681
759
  */
682
760
  function effectiveContextLimit() {
683
- if (config.provider === 'qwen') {
684
- return config.qwen.contextLimit > 0
685
- ? config.qwen.contextLimit
686
- : config.ui.contextLimit;
687
- }
761
+ const own = providerSectionNumber('contextLimit', 0);
762
+ if (own > 0)
763
+ return own;
688
764
  return config.ui.contextLimit;
689
765
  }
690
766
  /**
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.
767
+ * Re-derive the ACTIVE provider's `contextLimit` from a picked model id and
768
+ * persist it. Called by `/model` on an explicit pick (never on startup), so a
769
+ * manual `/config set <provider>.contextLimit` survives until the operator
770
+ * switches model again. An UNKNOWN model id leaves the limit alone: guessing
771
+ * would either fire auto-compact too early or too late. The live editor is
772
+ * repainted by the caller (it owns the `editor` reference); this only touches
773
+ * config.
697
774
  */
698
775
  function applyModelContextLimit(model) {
699
- const window = qwenContextWindow(model);
776
+ const section = providerConfigSection(config.provider);
777
+ if (!section)
778
+ return;
779
+ const window = config.provider === 'qwen'
780
+ ? qwenContextWindow(model)
781
+ : 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
+ // Dev runs (`npm run dev`, i.e. `--dev`) record the telemetry
855
+ // automatically: it is a diagnostic for the developer. For a regular
856
+ // user it stays OFF by default (see DEFAULTS in config.ts) and is
857
+ // opted into with `/config set browser.sendTelemetry true`.
858
+ sendTelemetry: config.browser.sendTelemetry || devMode,
859
+ logsDir: config.transcript.dir,
860
+ loginTimeoutMs: config.browser.loginTimeoutMs,
861
+ locale: currentLocale,
862
+ onAuthSave: persistAuth,
863
+ });
864
+ case 'deepseek':
865
+ default:
866
+ return new DeepSeekBrowser({
867
+ headless: headlessFor('deepseek'),
868
+ debug,
869
+ channel: config.browserChannel,
870
+ ...config.browser,
871
+ locale: currentLocale,
872
+ onAuthSave: persistAuth,
873
+ });
874
+ }
747
875
  }
748
876
  /**
749
877
  * Print the dialogue of the just-opened chat into the terminal. Called by
@@ -957,10 +1085,16 @@ async function main() {
957
1085
  // never block startup.
958
1086
  const warnUiHealth = (h) => {
959
1087
  if (h.missingCritical.length) {
960
- console.error(theme.warn(t('ui.probe_failed', { v: h.missingCritical.join(', ') })));
1088
+ console.error(theme.warn(t('ui.probe_failed', {
1089
+ v: h.missingCritical.join(', '),
1090
+ provider: providerLabel(config.provider),
1091
+ })));
961
1092
  }
962
1093
  else if (h.missing.length) {
963
- console.error(theme.warn(t('ui.probe_degraded', { v: h.missing.join(', ') })));
1094
+ console.error(theme.warn(t('ui.probe_degraded', {
1095
+ v: h.missing.join(', '),
1096
+ provider: providerLabel(config.provider),
1097
+ })));
964
1098
  }
965
1099
  };
966
1100
  try {
@@ -1021,14 +1155,21 @@ async function main() {
1021
1155
  await transcript.close();
1022
1156
  return;
1023
1157
  }
1158
+ if (providerNeedsVisibleBrowser(config.provider) &&
1159
+ headlessFor(config.provider)) {
1160
+ console.log(theme.warn(t('provider.needs_headed', { v: providerLabel(config.provider) })));
1161
+ }
1024
1162
  console.log(theme.system(t('msg.interactive')));
1025
1163
  console.log(theme.system(t('msg.queue_hint')));
1026
1164
  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.
1165
+ // No stored session yet (auth.json absent for THIS provider): the first
1166
+ // task will need a sign-in. The auto-login handles it, but a heads-up in
1167
+ // the banner saves the operator from wondering why the first task pauses.
1168
+ // The hint text describes the browser-based flow (see src/browser-login.ts);
1169
+ // the old wording promised a terminal prompt, which is no longer the main
1170
+ // path.
1030
1171
  if (!authMarkerExists()) {
1031
- console.log(theme.warn(t('msg.first_login_hint')));
1172
+ console.log(theme.warn(t('msg.first_login_hint', { provider: providerLabel(config.provider) })));
1032
1173
  }
1033
1174
  // Nudge when BACKLOG.md has grown large: /improve reads the whole file, so a
1034
1175
  // bloated backlog costs tokens on every self-improvement run. Best-effort.
@@ -1390,8 +1531,10 @@ async function main() {
1390
1531
  const { runLoginForm } = await import('./tui/run-login.js');
1391
1532
  return await runLoginForm({
1392
1533
  t,
1393
- initialUsername: config.browser.auth.username,
1394
- initialPassword: config.browser.auth.password,
1534
+ // The SAVED credentials of the ACTIVE provider (DeepSeek keeps
1535
+ // browser.auth; Qwen/GLM own their section).
1536
+ initialUsername: activeAuth().username,
1537
+ initialPassword: activeAuth().password,
1395
1538
  note: info.hasSavedPassword || info.savedUsername
1396
1539
  ? t('auth.form.note_saved')
1397
1540
  : undefined,
@@ -1737,10 +1880,11 @@ async function main() {
1737
1880
  }
1738
1881
  // The context window size feeds the status-bar percentage and its color.
1739
1882
  // 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') &&
1883
+ // no restart needed). ANY provider's limit matters: a /model pick rewrites
1884
+ // the active provider's own value, and a manual /config set on a non-active
1885
+ // provider must not leave the editor showing the old denominator when the
1886
+ // operator switches to it.
1887
+ if ((path === 'ui.contextLimit' || /\.contextLimit$/.test(path)) &&
1744
1888
  typeof value === 'number') {
1745
1889
  if (editor) {
1746
1890
  editor.setContextLimit(effectiveContextLimit());
@@ -1751,9 +1895,16 @@ async function main() {
1751
1895
  // by the browser at construction, so push them onto the live instance.
1752
1896
  // (deepThinking/webSearch are handled above because they also repaint the
1753
1897
  // status icons.)
1898
+ //
1899
+ // `browser.*` knobs belong to DEEPSEEK. Each other backend copies its
1900
+ // timings from its OWN section, so applying a `browser.*` value to the
1901
+ // live Qwen/GLM instance would clobber its interval with DeepSeek's
1902
+ // 15s (the real bug: /config set browser.minSendIntervalMs rewrote the
1903
+ // Qwen send interval).
1754
1904
  if (path.startsWith('browser.')) {
1755
1905
  const b = browser;
1756
- if (applyBrowserLiveConfig(path, value, b)) {
1906
+ if (config.provider === 'deepseek' &&
1907
+ applyBrowserLiveConfig(path, value, b)) {
1757
1908
  // minSendIntervalMs feeds the send countdown the UI renders.
1758
1909
  if (editor)
1759
1910
  editor.refreshStatus();
@@ -1764,15 +1915,15 @@ async function main() {
1764
1915
  undo: undo,
1765
1916
  checkpoints: checkpoints,
1766
1917
  });
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') {
1918
+ // A provider's OWN timing knobs feed its browser constructor. The LIVE
1919
+ // fields use the SAME names, so push them there when that backend is
1920
+ // active (a no-op otherwise). Generalized from the two hardcoded qwen.*
1921
+ // paths so a new backend needs no new branch here.
1922
+ const timingMatch = path.match(/^(qwen|glm)\.(minSendIntervalMs|answerTimeoutMs)$/);
1923
+ if (timingMatch) {
1771
1924
  const b = browser;
1772
- if (config.provider === 'qwen') {
1773
- const field = path === 'qwen.minSendIntervalMs'
1774
- ? 'minSendIntervalMs'
1775
- : 'answerTimeoutMs';
1925
+ if (config.provider === timingMatch[1]) {
1926
+ const field = timingMatch[2];
1776
1927
  if (field in b) {
1777
1928
  b[field] = value;
1778
1929
  if (editor)
@@ -1810,6 +1961,20 @@ async function main() {
1810
1961
  // The DeepSeek toggles themselves are clicked on the next send by
1811
1962
  // _applyToggles(), so this is safe even while a generation is in flight.
1812
1963
  function applyLiveToggle(cmd) {
1964
+ // GLM's flagship models (x-preview-l, glm-5.3, glm-4.6v) report
1965
+ // skip_think=false in the catalogue: thinking CANNOT be turned off, and
1966
+ // the site always sends enable_thinking=true. Flipping the switch would
1967
+ // only mislead the operator (and the icon would lie), so it is refused.
1968
+ if (cmd.kind === 'thinking' &&
1969
+ config.provider === 'glm' &&
1970
+ cmd.mode !== 'on') {
1971
+ const line = t('toggle.thinking_locked');
1972
+ if (editor)
1973
+ editor.printAbove(theme.warn(line));
1974
+ else
1975
+ console.log(theme.warn(line));
1976
+ return;
1977
+ }
1813
1978
  const b = browser;
1814
1979
  const cur = cmd.kind === 'thinking' ? b.deepThinking : b.webSearch;
1815
1980
  const next = cmd.mode === 'toggle' ? !cur : cmd.mode === 'on';
@@ -2404,7 +2569,7 @@ async function main() {
2404
2569
  if (lower === '/provider' || lower.startsWith('/provider ')) {
2405
2570
  const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2406
2571
  if (!arg || arg === 'status') {
2407
- const model = config.provider === 'qwen' ? config.qwen.model || '(default)' : 'n/a';
2572
+ const model = providerModelOf(config.provider) || '(default)';
2408
2573
  console.log(theme.system(t('provider.status', {
2409
2574
  v: config.provider,
2410
2575
  m: model,
@@ -2483,6 +2648,7 @@ async function main() {
2483
2648
  if (summary) {
2484
2649
  saveProviderHandoff({
2485
2650
  provider: arg,
2651
+ from,
2486
2652
  workdir: currentWorkdir,
2487
2653
  summary,
2488
2654
  task: lastTaskText || undefined,
@@ -2504,7 +2670,7 @@ async function main() {
2504
2670
  saveLastChat(currentChatId, currentWorkdir);
2505
2671
  // Persist the choice FIRST so a crash mid-switch still remembers it.
2506
2672
  config.provider = arg;
2507
- config.qwen.enabled = arg === 'qwen';
2673
+ setProviderEnabledFlag(arg);
2508
2674
  try {
2509
2675
  writeConfigValue('home', 'provider', arg);
2510
2676
  }
@@ -2535,6 +2701,9 @@ async function main() {
2535
2701
  currentChatId = await browser.getCurrentChatId().catch(() => null);
2536
2702
  if (editor) {
2537
2703
  editor.setProvider(providerLabel(arg));
2704
+ // The context window is provider-specific: without this the status
2705
+ // bar kept showing the OLD backend's denominator after a switch.
2706
+ editor.setContextLimit(effectiveContextLimit());
2538
2707
  }
2539
2708
  try {
2540
2709
  warnUiHealth(await browser.probeUiHealth());
@@ -2546,7 +2715,7 @@ async function main() {
2546
2715
  // The new backend did not come up: keep the OLD one active so the
2547
2716
  // session keeps working, and roll the persisted choice back.
2548
2717
  config.provider = from;
2549
- config.qwen.enabled = from === 'qwen';
2718
+ setProviderEnabledFlag(from);
2550
2719
  try {
2551
2720
  writeConfigValue('home', 'provider', from);
2552
2721
  }
@@ -2562,23 +2731,30 @@ async function main() {
2562
2731
  }
2563
2732
  continue;
2564
2733
  }
2565
- console.error(theme.warn(t('provider.usage')));
2734
+ console.error(theme.warn(t('provider.usage', { providers: PROVIDER_IDS.join('|') })));
2566
2735
  continue;
2567
2736
  }
2568
2737
  if (lower === '/model' || lower.startsWith('/model ')) {
2569
2738
  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')));
2739
+ // `/model` is available to ANY provider that exposes a model picker
2740
+ // (Qwen, GLM). DeepSeek has none, so the command is hidden from the
2741
+ // hints there and only explains itself when typed by hand.
2742
+ if (!providerHasModelPicker(config.provider)) {
2743
+ console.log(theme.system(t('model.unsupported', {
2744
+ provider: providerLabel(config.provider),
2745
+ })));
2575
2746
  continue;
2576
2747
  }
2748
+ const section = providerConfigSection(config.provider);
2749
+ const sectionObj = activeProviderSection();
2750
+ if (!section || !sectionObj)
2751
+ continue;
2752
+ const curModel = String(sectionObj.model || '');
2577
2753
  const qb = browser;
2578
2754
  if (!arg) {
2579
2755
  const models = qb.listModels ? await qb.listModels() : [];
2580
2756
  if (!models.length) {
2581
- console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2757
+ console.log(theme.system(t('model.list', { v: '(none)' })));
2582
2758
  continue;
2583
2759
  }
2584
2760
  // In the TUI, open a proper SELECT LIST instead of dumping text.
@@ -2589,12 +2765,14 @@ async function main() {
2589
2765
  try {
2590
2766
  const { selectListInk } = await import('./tui/run-select.js');
2591
2767
  picked = await selectListInk({
2592
- title: t('qwen.model_pick_title'),
2593
- initial: config.qwen.model || models[0].id,
2768
+ title: t('model.pick_title', {
2769
+ provider: providerLabel(config.provider),
2770
+ }),
2771
+ initial: curModel || models[0].id,
2594
2772
  items: models.map((m) => ({
2595
2773
  value: m.id,
2596
2774
  label: m.name || m.id,
2597
- detail: m.id === config.qwen.model ? t('tui.list.current') : m.id,
2775
+ detail: m.id === curModel ? t('tui.list.current') : m.id,
2598
2776
  })),
2599
2777
  locale: currentLocale,
2600
2778
  });
@@ -2607,32 +2785,35 @@ async function main() {
2607
2785
  editor.resume();
2608
2786
  }
2609
2787
  if (picked) {
2610
- config.qwen.model = picked;
2788
+ sectionObj.model = picked;
2611
2789
  if (typeof qb.model === 'string')
2612
2790
  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).
2791
+ // Derive the context window from the picked model: windows are
2792
+ // per-model, and this number is the denominator of the
2793
+ // auto-compact threshold. The operator's manual
2794
+ // `/config set <provider>.contextLimit` still wins afterwards
2795
+ // (this only runs on an explicit pick).
2618
2796
  applyModelContextLimit(picked);
2619
2797
  if (editor) {
2620
2798
  editor.setContextLimit(effectiveContextLimit());
2621
2799
  editor.refreshStatus();
2622
2800
  }
2623
2801
  try {
2624
- writeConfigValue('home', 'qwen.model', picked);
2802
+ writeConfigValue('home', section + '.model', picked);
2625
2803
  }
2626
2804
  catch { }
2627
- console.log(theme.system(t('qwen.model_set', { v: picked })));
2805
+ console.log(theme.system(t('model.set', {
2806
+ provider: providerLabel(config.provider),
2807
+ v: picked,
2808
+ })));
2628
2809
  }
2629
2810
  continue;
2630
2811
  }
2631
2812
  const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2632
- console.log(theme.system(t('qwen.model_list', { v: lines })));
2813
+ console.log(theme.system(t('model.list', { v: lines })));
2633
2814
  continue;
2634
2815
  }
2635
- config.qwen.model = arg;
2816
+ sectionObj.model = arg;
2636
2817
  if (typeof qb.model === 'string')
2637
2818
  qb.model = arg;
2638
2819
  applyModelContextLimit(arg);
@@ -2641,10 +2822,10 @@ async function main() {
2641
2822
  editor.refreshStatus();
2642
2823
  }
2643
2824
  try {
2644
- writeConfigValue('home', 'qwen.model', arg);
2825
+ writeConfigValue('home', section + '.model', arg);
2645
2826
  }
2646
2827
  catch { }
2647
- console.log(theme.system(t('qwen.model_set', { v: arg })));
2828
+ console.log(theme.system(t('model.set', { provider: providerLabel(config.provider), v: arg })));
2648
2829
  continue;
2649
2830
  }
2650
2831
  if (lower === '/chat') {
@@ -3328,6 +3509,37 @@ async function main() {
3328
3509
  console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3329
3510
  continue;
3330
3511
  }
3512
+ if (lower === '/limits') {
3513
+ const { readSendEvents, summarizeSends, sendLogPath } = await import('./send-telemetry.js');
3514
+ const logsDir = config.transcript.dir;
3515
+ const events = readSendEvents(logsDir, config.provider);
3516
+ if (!events.length) {
3517
+ console.log(theme.system(t('limits.none', { path: sendLogPath(logsDir, config.provider) })));
3518
+ }
3519
+ else {
3520
+ const st = summarizeSends(events);
3521
+ console.log(theme.system(t('limits.title', { provider: providerLabel(config.provider) })));
3522
+ for (const [label, n] of [
3523
+ ['10m', st.last10m],
3524
+ ['30m', st.last30m],
3525
+ ['60m', st.last60m],
3526
+ ]) {
3527
+ console.log(' ' + t('limits.window', { label, n: String(n) }));
3528
+ }
3529
+ console.log(' ' +
3530
+ t('limits.rate', {
3531
+ n: String(st.rateLimits),
3532
+ sec: String(Math.round(st.avgBlockMs / 1000)),
3533
+ }));
3534
+ const adaptive = providerAdaptiveThrottle(config.provider);
3535
+ console.log(' ' +
3536
+ t('limits.interval', {
3537
+ sec: String(Math.round(browser.sendIntervalMs() / 1000)),
3538
+ adaptive: adaptive ? t('limits.adaptive_on') : '',
3539
+ }));
3540
+ }
3541
+ continue;
3542
+ }
3331
3543
  if (lower === '/cost' || lower === '/usage') {
3332
3544
  let stats = mod.commands.summarizeTranscript([]);
3333
3545
  if (transcript.file) {
@@ -3413,7 +3625,11 @@ async function main() {
3413
3625
  hasOrigin: gitOk
3414
3626
  ? (await runGitArgs(['remote'], currentWorkdir, 5000)).includes('origin')
3415
3627
  : undefined,
3416
- minSendIntervalMs: config.browser.minSendIntervalMs,
3628
+ // The ACTIVE browser's own interval: a per-provider timing
3629
+ // (Qwen 4s, GLM 0) used to be reported as DeepSeek's 15s.
3630
+ minSendIntervalMs: browser.sendIntervalMs(),
3631
+ adaptiveThrottle: providerAdaptiveThrottle(config.provider),
3632
+ displayAvailable: canShowWindow(process.env, process.platform),
3417
3633
  sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
3418
3634
  headless: !!headless,
3419
3635
  headlessUaCached: await headlessUACacheReady().catch(() => false),
@@ -3421,7 +3637,9 @@ async function main() {
3421
3637
  uiMissing: uiHealth?.missing,
3422
3638
  uiMissingCritical: uiHealth?.missingCritical,
3423
3639
  provider: config.provider,
3424
- providerModel: config.provider === 'qwen' ? config.qwen.model : undefined,
3640
+ providerModel: providerConfigSection(config.provider)
3641
+ ? providerModelOf(config.provider)
3642
+ : undefined,
3425
3643
  }, t)));
3426
3644
  continue;
3427
3645
  }
@@ -3828,7 +4046,7 @@ async function main() {
3828
4046
  if (handoff) {
3829
4047
  finalTaskText =
3830
4048
  buildHandoffText({
3831
- from: handoff.provider === 'qwen' ? 'DeepSeek' : 'Qwen',
4049
+ from: handoffFromLabel(handoff),
3832
4050
  to: providerLabel(config.provider),
3833
4051
  summary: handoff.summary,
3834
4052
  task: handoff.task,