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/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.81.1] - 2026-10-21
11
+
12
+ ### Added
13
+
14
+ - send telemetry is opt-in (on automatically in dev)
15
+
16
+ ### Fixed
17
+
18
+ - **config:** mask a password in the /config set confirmation
19
+
20
+ ### Changed
21
+
22
+ - apply prettier to the GLM provider and send runtime
23
+
24
+ ## [2.81.0] - 2026-10-21
25
+
26
+ ### Added
27
+
28
+ - auto-compact by default, GLM reasoning effort low
29
+ - **glm:** tell the model it runs on the operator's machine; fix the runtime rewrite URL match
30
+ - **glm:** neutralise z.ai's agent runtime so zames tools actually run
31
+ - **glm:** run with a visible browser by default (the site blocks headless)
32
+ - **glm:** thinking is always on and cannot be toggled off
33
+ - **glm:** verify mode flags and attach files through the upload button
34
+ - send telemetry for all providers and adaptive throttle (enabled for GLM)
35
+ - GLM (chat.z.ai) provider with anonymized recon fixtures
36
+ - browser-based login (auto-login first, headed window fallback, headless after)
37
+
38
+ ### Fixed
39
+
40
+ - **glm:** force web search off and report a busy page instead of a missing button
41
+ - **glm:** no web-search noise, quota fails fast, the model is told not to browse
42
+ - **glm:** the smoke sends the system-prompt into the same chat; the mode check reads the rewritten body
43
+ - **glm:** a signed-in session is not reported as a guest
44
+ - drop the unused headless flag
45
+ - **glm:** word the mode warning accurately (the smoke passed with it on)
46
+ - persist the sign-in session cookie so it survives a restart
47
+ - a successful auto-login saves the credentials it used
48
+ - per-provider credentials are writable (missing from the config schema)
49
+ - DeepSeek signs in through the browser window before the terminal form
50
+ - the TUI login fallback prefills the active provider's credentials
51
+ - doctor renders the display row and the adaptive pause
52
+ - **glm:** report the slider captcha instead of a silent no-answer
53
+ - handoff label and live config for more than two providers
54
+ - per-provider auth marker, credentials and shared headless-UA cache
55
+
56
+ ### Changed
57
+
58
+ - provider capabilities instead of qwen-only checks
59
+
10
60
  ## [2.80.8] - 2026-10-20
11
61
 
12
62
  ### Changed
package/README.md CHANGED
@@ -65,8 +65,9 @@ zames does not call the model API. It launches a headless Chromium with a
65
65
  persistent profile, signs in to a chat web app like a human, types the task
66
66
  into the chat box, and reads the answer back.
67
67
 
68
- Two chat backends are available, switched with `/provider <deepseek|qwen>`:
69
- **DeepSeek** (`chat.deepseek.com`, the default) and **Qwen** (`chat.qwen.ai`).
68
+ Three chat backends are available, switched with `/provider <deepseek|qwen|glm>`:
69
+ **DeepSeek** (`chat.deepseek.com`, the default), **Qwen** (`chat.qwen.ai`)
70
+ and **GLM** (`chat.z.ai`).
70
71
  The switch is **live** — zames closes the current backend and opens the other
71
72
  one in place, no restart needed. Each keeps its own persistent profile
72
73
  (`~/.zames/profile` and `~/.zames/qwen-profile`), so switching never signs the
@@ -97,7 +98,7 @@ Key pieces:
97
98
 
98
99
  - [Quick start](#quick-start) · [Features](#features) · [Why zames?](#why-zames) · [Requirements](#requirements)
99
100
  - [How it works](#how-it-works) · [Installation](#installation) · [Signing in](#signing-in) · [Usage](#usage)
100
- - [Switching providers (DeepSeek / Qwen)](#switching-providers-deepseek--qwen)
101
+ - [Switching providers (DeepSeek / Qwen / GLM)](#switching-providers-deepseek--qwen--glm)
101
102
  - [Tools](#tools) · [Slash commands](#slash-commands)
102
103
  - [Project context, skills and memory](#project-context-skills-and-memory) · [MCP (external tools)](#mcp-external-tools) · [Configuration](#configuration)
103
104
  - [FAQ](#faq) · [Links](#links) · [License](#license)
@@ -152,13 +153,18 @@ Key pieces:
152
153
  The browser runs **headless by default**. When the active backend requires a
153
154
  sign-in, zames:
154
155
 
155
- 1. reuses the session stored in the persistent profile (`~/.zames/profile` for
156
- DeepSeek, `~/.zames/qwen-profile` for Qwen) if it is still valid;
156
+ 1. reuses the session stored in the persistent profile
157
+ (`~/.zames/profile`, `~/.zames/qwen-profile` or `~/.zames/glm-profile`)
158
+ if it is still valid;
157
159
  2. otherwise signs in automatically with the saved credentials
158
- (`browser.auth.username` / `browser.auth.password`);
159
- 3. otherwise asks you for the login/password in the terminal (in a TTY) and,
160
- after a successful sign-in, remembers them for next time;
161
- 4. otherwise falls back to a manual sign-in hint.
160
+ (`browser.auth.username` / `browser.auth.password` for DeepSeek,
161
+ `qwen.auth.*` / `glm.auth.*` for the others);
162
+ 3. otherwise opens a **visible browser window** so YOU sign in there — a
163
+ captcha (DeepSeek and z.ai both use one) cannot be typed into a terminal.
164
+ zames notices the sign-in and returns to headless; the credentials you
165
+ typed are saved for the next silent auto-login;
166
+ 4. only when no window can be shown (no display, a non-interactive run) does it
167
+ fall back to the terminal form.
162
168
 
163
169
  To sign in by hand (for example, if the site shows a captcha), run with a
164
170
  visible window:
@@ -178,7 +184,7 @@ version). You do not need `--headed` just to log in.
178
184
  Credentials and toggles can also be edited from `/config`
179
185
  (`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
180
186
 
181
- ## Switching providers (DeepSeek / Qwen)
187
+ ## Switching providers (DeepSeek / Qwen / GLM)
182
188
 
183
189
  zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
184
190
  **Qwen** (`chat.qwen.ai`):
@@ -206,6 +212,25 @@ Notes:
206
212
  GitHub — no phone required). zames auto-logins from the saved credentials or
207
213
  asks in the terminal, exactly like DeepSeek. `/model` changes the Qwen model
208
214
  at runtime.
215
+ - **GLM** (`chat.z.ai`) is the third backend. Its first message from a fresh
216
+ profile is gated by a slider captcha, so sign in once in the visible window;
217
+ after that the stored session is reused. `/model` lists and picks GLM
218
+ models too.
219
+ - **GLM runs with a VISIBLE browser window.** Measured: the SAME signed-in
220
+ session is fine in a headed window but every send from HEADLESS is stopped by
221
+ the site's slider captcha (a different puzzle each time, so it cannot be
222
+ solved programmatically). zames therefore launches GLM headed by default and
223
+ prints a note about it; `--headless` still forces headless (sends will hit
224
+ the captcha). `--headed` is accepted everywhere.
225
+ - **Send telemetry**: every send is recorded in
226
+ `~/.zames/logs/sends-<provider>.jsonl` (timing, mode flags, the outcome and
227
+ the server error — never the prompt or the answer). Turn it off with
228
+ `/config set browser.sendTelemetry false`. `/limits` shows the recent sends
229
+ and the current pause. GLM additionally uses an **adaptive pause**: it starts
230
+ at 0 and backs off from a REAL rate limit.
231
+ - **Warning**: automating a web chat may violate the service's terms of use and
232
+ can get an account blocked. GLM (z.ai) has an official API — prefer it for
233
+ heavy automation.
209
234
  - File attachments on Qwen go through the mode menu's **Upload attachment**
210
235
  item; DeepSeek uses its own paperclip.
211
236
  - `/doctor` shows the active provider and the UI health of that provider's
@@ -648,11 +673,14 @@ zames не вызывает API модели. Он запускает headless C
648
673
  профилем, входит в веб-чат как человек, печатает задачу в поле чата и читает
649
674
  ответ обратно.
650
675
 
651
- Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen>`:
652
- **DeepSeek** (`chat.deepseek.com`, по умолчанию) и **Qwen** (`chat.qwen.ai`).
676
+ Доступны три бэкенда, переключаются командой
677
+ `/provider <deepseek|qwen|glm>`:
678
+ **DeepSeek** (`chat.deepseek.com`, по умолчанию), **Qwen** (`chat.qwen.ai`)
679
+ и **GLM** (`chat.z.ai`).
653
680
  Переключение **живое** — zames закрывает текущий бэкенд и открывает другой на
654
681
  месте, без перезапуска. У каждого свой постоянный профиль
655
- (`~/.zames/profile` и `~/.zames/qwen-profile`), поэтому переключение не
682
+ (`~/.zames/profile`, `~/.zames/qwen-profile`, `~/.zames/glm-profile`),
683
+ поэтому переключение не
656
684
  разлогинивает другой.
657
685
 
658
686
  <p align="center">
@@ -682,7 +710,7 @@ zames не вызывает API модели. Он запускает headless C
682
710
 
683
711
  - [Быстрый старт](#быстрый-старт) · [Возможности](#возможности) · [Почему zames?](#почему-zames) · [Требования](#требования)
684
712
  - [Как это работает](#как-это-работает) · [Установка](#установка) · [Вход в аккаунт](#вход-в-аккаунт) · [Использование](#использование)
685
- - [Переключение провайдера (DeepSeek / Qwen)](#переключение-провайдера-deepseek--qwen)
713
+ - [Переключение провайдера (DeepSeek / Qwen / GLM)](#переключение-провайдера-deepseek--qwen--glm)
686
714
  - [Инструменты](#инструменты) · [Слэш-команды](#слэш-команды)
687
715
  - [Контекст проекта, навыки и память](#контекст-проекта-навыки-и-память) · [MCP (внешние инструменты)](#mcp-внешние-инструменты) · [Конфигурация](#конфигурация)
688
716
  - [FAQ](#faq) · [Ссылки](#ссылки) · [Лицензия](#лицензия)
@@ -740,13 +768,17 @@ zames не вызывает API модели. Он запускает headless C
740
768
  Браузер по умолчанию работает **headless**. Когда активный бэкенд требует
741
769
  входа, zames:
742
770
 
743
- 1. переиспользует сессию из постоянного профиля (`~/.zames/profile` для
744
- DeepSeek, `~/.zames/qwen-profile` для Qwen), если она ещё валидна;
771
+ 1. переиспользует сессию из постоянного профиля
772
+ (`~/.zames/profile`, `~/.zames/qwen-profile` или `~/.zames/glm-profile`),
773
+ если она ещё валидна;
745
774
  2. иначе входит автоматически по сохранённым учётным данным
746
- (`browser.auth.username` / `browser.auth.password`);
747
- 3. иначе спрашивает логин/пароль в терминале (в TTY) и после успешного входа
748
- запоминает их для следующего раза;
749
- 4. иначе показывает подсказку про ручной вход.
775
+ (`browser.auth.*` у DeepSeek, `qwen.auth.*` / `glm.auth.*` у остальных);
776
+ 3. иначе открывает **видимое окно браузера**, чтобы вы вошли сами — капчу
777
+ (она есть и у DeepSeek, и у z.ai) в терминал не ввести. zames замечает
778
+ вход и возвращается в headless; введённые вами учётные данные сохраняются
779
+ для следующего тихого автовхода;
780
+ 4. и только если окно показать нельзя (нет дисплея, неинтерактивный запуск),
781
+ переходит к форме в терминале.
750
782
 
751
783
  Чтобы войти руками (например, если сайт показывает капчу), запустите с
752
784
  видимым окном:
@@ -766,7 +798,7 @@ Headless работает из коробки: headless Chrome обычно пр
766
798
  Учётные данные и переключатели можно также править из `/config`
767
799
  (`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
768
800
 
769
- ## Переключение провайдера (DeepSeek / Qwen)
801
+ ## Переключение провайдера (DeepSeek / Qwen / GLM)
770
802
 
771
803
  zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по умолчанию) и
772
804
  **Qwen** (`chat.qwen.ai`):
@@ -793,6 +825,24 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
793
825
  несколько сообщений в день; для долгих прогонов войдите (email / Google /
794
826
  GitHub — телефон не нужен). zames сам логинится по сохранённым кредам или
795
827
  спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на лету.
828
+ - **GLM** (`chat.z.ai`) — третий бэкенд. Первое сообщение с нового
829
+ профиля закрыто капчей-слайдером, поэтому войдите один раз в видимом окне;
830
+ дальше сохранённая сессия переиспользуется. `/model` работает и для GLM.
831
+ - **GLM работает с ВИДИМЫМ окном браузера.** Замерено: одна и та же
832
+ залогиненная сессия в окне отправляет нормально, а КАЖДАЯ отправка из
833
+ headless упирается в капчу-слайдер сайта (картинка каждый раз новая, поэтому
834
+ программно её не пройти). Поэтому zames запускает GLM с окном по умолчанию и
835
+ пишет об этом при старте; `--headless` по-прежнему форсирует headless
836
+ (отправки будут упираться в капчу). `--headed` работает везде.
837
+ - **Телеметрия отправок**: каждая отправка пишется в
838
+ `~/.zames/logs/sends-<provider>.jsonl` (тайминги, флаги режимов, исход и
839
+ ошибка сервера — без промптов и ответов). Отключается через
840
+ `/config set browser.sendTelemetry false`. Команда `/limits` показывает
841
+ последние отправки и текущую паузу. У GLM вдобавок **адаптивная пауза**: она
842
+ начинается с 0 и растёт только после НАСТОЯЩЕГО лимита.
843
+ - **Предупреждение**: автоматизация веб-чата может нарушать условия
844
+ использования сервиса и привести к блокировке аккаунта. У GLM (z.ai) есть
845
+ официальный API — для тяжёлой автоматизации лучше он.
796
846
  - Вложения в Qwen идут через пункт **Upload attachment** в меню режима; в
797
847
  DeepSeek — своя скрепка.
798
848
  - `/doctor` показывает активный провайдер и состояние UI его страницы.
@@ -0,0 +1,179 @@
1
+ // Interactive, BROWSER-BASED sign-in — shared by every provider.
2
+ //
3
+ // WHY: DeepSeek (and now z.ai) often answers a headless login with a captcha
4
+ // that neither a terminal prompt nor a filled form can pass. A human has to
5
+ // sign in inside a VISIBLE window; zames then notices the session and goes
6
+ // back to headless. Before this module each provider had its own ad-hoc flow,
7
+ // so the policy lived in three places and none of them offered the window.
8
+ //
9
+ // The module is PURE in the sense that it owns NO Playwright and NO disk: the
10
+ // browser supplies `probeLogin`, `relaunch`, `openLoginPage`, ... and the
11
+ // decision sequence below is unit-tested with injected doubles
12
+ // (test/browser-login.test.ts).
13
+ //
14
+ // Order (the operator's requirement):
15
+ // 1. probeLogin() === 'in' -> done, no window at all
16
+ // 2. autoLogin() with saved creds -> done (this is the FIRST step after
17
+ // the probe; credentials are permanent)
18
+ // 3. guest is allowed and no login was requested -> work as a guest
19
+ // 4. not interactive -> LoginRequiredError (fast, no window)
20
+ // 5. no display -> fall back to the in-TUI forms
21
+ // 6. headless -> relaunchSafe(headed), sign in
22
+ // 7. wait until probeLogin() === 'in' (poll), the operator closes the window
23
+ // (= done) or presses Esc (= cancel)
24
+ // 8. save credentials captured from the form, persist the session marker
25
+ // 9. not stayHeaded -> relaunchSafe(headless); if the
26
+ // session did not carry over, go back
27
+ // to headed ONCE and stay there.
28
+ /** A sign-in is required and no interactive window can be offered. */
29
+ export class LoginRequiredError extends Error {
30
+ constructor(provider) {
31
+ super(provider + ': a sign-in is required (non-interactive)');
32
+ this.name = 'LoginRequiredError';
33
+ }
34
+ }
35
+ /** The operator cancelled the sign-in (Esc / Ctrl+C). */
36
+ export class LoginCancelledError extends Error {
37
+ constructor() {
38
+ super('sign-in cancelled');
39
+ this.name = 'LoginCancelledError';
40
+ }
41
+ }
42
+ export async function interactiveBrowserLogin(deps) {
43
+ // 1) A live session: nothing to do (and no window flashes).
44
+ const first = await deps.probeLogin().catch(() => 'unknown');
45
+ if (first === 'in')
46
+ return 'already';
47
+ // 2) Saved credentials — the FIRST fallback. They are permanent (the
48
+ // operator's decision), so a logged-out profile signs in silently.
49
+ const auto = await deps.autoLogin().catch(() => false);
50
+ if (auto) {
51
+ await deps.persistSession().catch(() => { });
52
+ return 'auto';
53
+ }
54
+ // 3) A guest is usable and no window is needed.
55
+ if (deps.guestAllowed)
56
+ return 'guest';
57
+ // 4) Non-interactive: fail fast instead of hanging on a window nobody sees.
58
+ if (!deps.interactive)
59
+ throw new LoginRequiredError(deps.provider);
60
+ // 5) No display: the in-TUI form is the accepted fallback (DESIGN-NOTES
61
+ // "Login stays inside the TUI").
62
+ if (!deps.canShowWindow) {
63
+ if (deps.tuiLogin) {
64
+ const ok = await deps.tuiLogin().catch(() => false);
65
+ if (ok) {
66
+ await deps.persistSession().catch(() => { });
67
+ return 'tui';
68
+ }
69
+ }
70
+ throw new LoginRequiredError(deps.provider);
71
+ }
72
+ // 6) Open the visible window and wait for the human.
73
+ const inWindow = await runHeadedLogin(deps);
74
+ if (!inWindow) {
75
+ if (deps.isAborted())
76
+ throw new LoginCancelledError();
77
+ throw new LoginRequiredError(deps.provider);
78
+ }
79
+ await deps.persistSession().catch(() => { });
80
+ // 7) Back to headless unless the operator asked for a visible window.
81
+ if (!deps.stayHeaded) {
82
+ await deps.relaunch(false).catch(() => { });
83
+ const back = await deps.probeLogin().catch(() => 'unknown');
84
+ if (back !== 'in') {
85
+ // The session did not carry over (a session cookie or an SPA token was
86
+ // lost on the relaunch). Stay visible ONCE — never loop.
87
+ await deps.relaunch(true).catch(() => { });
88
+ deps.notice('login.headless_failed');
89
+ }
90
+ }
91
+ await deps.gotoChat().catch(() => { });
92
+ return 'window';
93
+ }
94
+ /**
95
+ * Show the window, drive the human through the sign-in and return true when
96
+ * the session became active (or the operator closed the window = "done").
97
+ */
98
+ async function runHeadedLogin(deps) {
99
+ if (!deps.stayHeaded)
100
+ await deps.relaunch(true).catch(() => { });
101
+ await deps.openLoginPage().catch(() => { });
102
+ await deps.prefill().catch(() => { });
103
+ await deps.captureCredentials().catch(() => { });
104
+ deps.notice('login.browser_prompt');
105
+ const deadline = deps.now() + deps.timeoutMs;
106
+ while (deps.now() < deadline) {
107
+ if (deps.isAborted())
108
+ return false;
109
+ const r = await deps.probeLogin().catch(() => 'unknown');
110
+ if (r === 'in')
111
+ return true;
112
+ // A closed window is the operator's "I am done" signal: the browser object
113
+ // may still hold the session cookies, so verify once more and accept.
114
+ if (deps.windowClosed()) {
115
+ deps.notice('login.window_closed');
116
+ const again = await deps
117
+ .probeLogin()
118
+ .catch(() => 'unknown');
119
+ return again === 'in';
120
+ }
121
+ await deps.sleep(1000);
122
+ }
123
+ deps.notice('login.timeout');
124
+ const last = await deps.probeLogin().catch(() => 'unknown');
125
+ return last === 'in';
126
+ }
127
+ /**
128
+ * PURE: can a browser window be shown? Only a PRELIMINARY filter — WSL2 often
129
+ * exports DISPLAY/WAYLAND_DISPLAY even when WSLg is broken, so the headed
130
+ * launch itself must still be wrapped in try/catch with a timeout.
131
+ */
132
+ export function canShowWindow(env, platform) {
133
+ if (env.ZAMES_NONINTERACTIVE_LOGIN === '1')
134
+ return false;
135
+ if (env.CI === 'true' || env.CI === '1')
136
+ return false;
137
+ if (platform === 'win32' || platform === 'darwin')
138
+ return true;
139
+ // Linux/X11/Wayland: a display variable must be set.
140
+ if (env.DISPLAY || env.WAYLAND_DISPLAY)
141
+ return true;
142
+ return false;
143
+ }
144
+ /**
145
+ * PURE: is this run allowed to open an interactive window at all? One-shot
146
+ * (--task), a scheduler job and a non-TTY all say no; the env kill switch
147
+ * ZAMES_NONINTERACTIVE_LOGIN=1 says no too (the smoke scripts set it so a
148
+ * cold login fails fast instead of hanging).
149
+ */
150
+ export function loginIsInteractive(opts) {
151
+ if (opts.env.ZAMES_NONINTERACTIVE_LOGIN === '1')
152
+ return false;
153
+ if (opts.oneShot)
154
+ return false;
155
+ if (opts.scheduled)
156
+ return false;
157
+ return opts.isTTY;
158
+ }
159
+ /**
160
+ * PURE: classify a login state from the cheap signals. The rule that matters
161
+ * is that the INPUT FIELD alone proves nothing: a guest sees it too, so the
162
+ * classifier relies on the API role / a token / an explicit sign-in control.
163
+ */
164
+ export function classifyLoginProbe(input) {
165
+ const role = String(input.role || '').toLowerCase();
166
+ if (role === 'user' || role === 'admin')
167
+ return 'in';
168
+ if (role === 'guest')
169
+ return input.signInVisible ? 'out' : 'in';
170
+ if (input.captcha)
171
+ return 'challenge';
172
+ if (input.hasToken === true)
173
+ return 'in';
174
+ if (input.hasToken === false)
175
+ return 'out';
176
+ if (input.signInVisible)
177
+ return 'out';
178
+ return 'unknown';
179
+ }
@@ -106,23 +106,49 @@ export const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
106
106
  // relaunch" dance happens only once (it otherwise doubled every headless
107
107
  // start). Keying by the executable path means a Playwright update (new engine
108
108
  // path) invalidates the cache and we re-derive the UA once for the new build.
109
+ //
110
+ // The cache holds ONE entry PER ENGINE, not per provider: the UA depends on
111
+ // the browser build, not on the site. It used to be a single {engine,ua}
112
+ // record with providers appending a suffix (`engine:qwen`), so each provider
113
+ // overwrote the other's entry and a /provider switch relaunched the browser
114
+ // once per side. The file now maps engine key -> UA; a legacy single record is
115
+ // still read.
109
116
  const HEADLESS_UA_CACHE = path.join(os.homedir(), '.zames', 'headless-ua.json');
110
117
  // Pure: pick a reusable UA from the cache file's text for the given engine, or
111
118
  // null. Exported so the policy is unit-tested without touching ~/.zames.
112
119
  export function parseHeadlessUACache(raw, engine) {
113
120
  try {
114
121
  const j = JSON.parse(raw);
115
- if (j &&
116
- j.engine === engine &&
117
- typeof j.ua === 'string' &&
118
- j.ua &&
119
- !/Headless/i.test(j.ua)) {
120
- return j.ua;
121
- }
122
+ if (!j || typeof j !== 'object')
123
+ return null;
124
+ const fromMap = j.engines && typeof j.engines === 'object' ? j.engines[engine] : '';
125
+ const legacy = j.engine === engine ? j.ua : '';
126
+ const ua = fromMap || legacy;
127
+ if (typeof ua === 'string' && ua && !/Headless/i.test(ua))
128
+ return ua;
122
129
  }
123
130
  catch { }
124
131
  return null;
125
132
  }
133
+ // Pure: merge a UA into the cache file's text, keeping every OTHER engine's
134
+ // entry. Exported so the multi-engine contract is unit-tested.
135
+ export function mergeHeadlessUACache(raw, engine, ua) {
136
+ let engines = {};
137
+ try {
138
+ const j = JSON.parse(raw);
139
+ if (j && j.engines && typeof j.engines === 'object') {
140
+ engines = { ...j.engines };
141
+ }
142
+ else if (j && typeof j.engine === 'string' && typeof j.ua === 'string') {
143
+ // Migrate a legacy single record into the map.
144
+ engines[j.engine] = j.ua;
145
+ }
146
+ }
147
+ catch { }
148
+ if (engine)
149
+ engines[engine] = ua;
150
+ return JSON.stringify({ engines }, null, 2);
151
+ }
126
152
  export function headlessEngineKey() {
127
153
  try {
128
154
  return chromium.executablePath();
@@ -147,7 +173,12 @@ export async function writeCachedHeadlessUA(engine, ua) {
147
173
  return;
148
174
  try {
149
175
  await fs.mkdir(path.dirname(HEADLESS_UA_CACHE), { recursive: true });
150
- await fs.writeFile(HEADLESS_UA_CACHE, JSON.stringify({ engine, ua }, null, 2), 'utf-8');
176
+ let prev = '';
177
+ try {
178
+ prev = await fs.readFile(HEADLESS_UA_CACHE, 'utf-8');
179
+ }
180
+ catch { }
181
+ await fs.writeFile(HEADLESS_UA_CACHE, mergeHeadlessUACache(prev, engine, ua), 'utf-8');
151
182
  }
152
183
  catch { }
153
184
  }
@@ -169,7 +200,80 @@ export function sanitizeHeadlessUA(ua) {
169
200
  }
170
201
  // Written after a successful sign-in so the next launch knows a session was
171
202
  // stored in the persistent profile (used by /doctor and diagnostics).
203
+ //
204
+ // The marker is PER PROVIDER (`{ providers: { deepseek: {at}, qwen: {at} } }`).
205
+ // It used to be one shared file: Qwen wrote `{provider:'qwen'}` and DeepSeek a
206
+ // bare `{at}`, each overwriting the other, so /doctor and the startup banner
207
+ // reported "no session" for whichever provider had signed in second. A legacy
208
+ // record without `providers` still reads: `{provider:'qwen'}` means Qwen and a
209
+ // bare `{at}` means DeepSeek (the only writer before the second backend).
172
210
  export const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
211
+ /**
212
+ * PURE: does the marker text say `provider` has a stored session? Legacy
213
+ * shapes are honored (see the comment above AUTH_MARKER_FILE).
214
+ */
215
+ export function authMarkerHas(raw, provider) {
216
+ if (!raw)
217
+ return false;
218
+ let j;
219
+ try {
220
+ j = JSON.parse(raw);
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ if (!j || typeof j !== 'object')
226
+ return false;
227
+ if (j.providers && typeof j.providers === 'object') {
228
+ return !!j.providers[provider];
229
+ }
230
+ if (typeof j.provider === 'string')
231
+ return j.provider === provider;
232
+ // Legacy DeepSeek marker: a bare `{at}` with no provider field.
233
+ return provider === 'deepseek' && typeof j.at === 'string';
234
+ }
235
+ /**
236
+ * PURE: merge a "signed in at" timestamp for `provider` into the marker text,
237
+ * KEEPING every other provider's entry. A legacy record is migrated.
238
+ */
239
+ export function mergeAuthMarker(raw, provider, at) {
240
+ const providers = {};
241
+ try {
242
+ const j = JSON.parse(raw);
243
+ if (j && j.providers && typeof j.providers === 'object') {
244
+ for (const [k, v] of Object.entries(j.providers))
245
+ providers[k] = v || {};
246
+ }
247
+ else if (j && typeof j === 'object') {
248
+ if (j.provider === 'qwen')
249
+ providers.qwen = { at: j.at };
250
+ else if (typeof j.at === 'string')
251
+ providers.deepseek = { at: j.at };
252
+ }
253
+ }
254
+ catch { }
255
+ providers[provider] = { at };
256
+ return JSON.stringify({ providers }, null, 2);
257
+ }
258
+ // Best-effort: read the marker text from disk ('' when absent/unreadable).
259
+ export async function readAuthMarker() {
260
+ try {
261
+ return await fs.readFile(AUTH_MARKER_FILE, 'utf-8');
262
+ }
263
+ catch {
264
+ return '';
265
+ }
266
+ }
267
+ // Best-effort: mark `provider` as signed in, keeping the other providers'
268
+ // entries. Never throws (a marker is informational, not load-bearing).
269
+ export async function writeAuthMarker(provider) {
270
+ try {
271
+ await fs.mkdir(path.dirname(AUTH_MARKER_FILE), { recursive: true });
272
+ const prev = await readAuthMarker();
273
+ await fs.writeFile(AUTH_MARKER_FILE, mergeAuthMarker(prev, provider, new Date().toISOString()), 'utf-8');
274
+ }
275
+ catch { }
276
+ }
173
277
  export function isGenerationIncompleteText(text) {
174
278
  const t = String(text || '');
175
279
  return (GENERATION_ERR_RE.test(t) ||