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.
@@ -348,19 +348,72 @@ export function extractTokenUsage(body) {
348
348
  return found;
349
349
  }
350
350
  // Saves the DeepSeek network response body to disk for post-mortem analysis.
351
- // The files live in ~/.zames/net-log — from them the real answer format is visible.
352
- // DEBUG ONLY: disabled unless ZAMES_NET_DEBUG=1. It writes a file per network
353
- // response (thousands of files / tens of MB) and is not needed for the agent
354
- // to work — the answer is taken from extractAnswer() in memory.
351
+ // DEBUG ONLY: disabled unless ZAMES_NET_DEBUG=1.
352
+ //
353
+ // The dumps live in ~/.zames/net-log and one file PER RESPONSE is written, so
354
+ // an unattended `npm run dev` (which sets ZAMES_NET_DEBUG=1) used to grow the
355
+ // directory without bound: 77k files / 800 MB after a few days. On WSL that
356
+ // directory sits inside ext4.vhdx, and the space of thousands of tiny deleted
357
+ // files is never returned to Windows — the host disk fills up. So the log is
358
+ // ROTATED here: `NET_LOG_KEEP` newest dumps are kept and a rotation runs at
359
+ // most once every `NET_LOG_ROTATE_MS` (listing the dir on every response would
360
+ // itself cost time on a huge directory).
361
+ const NET_LOG_KEEP = 200;
362
+ const NET_LOG_ROTATE_MS = 5 * 60_000;
363
+ const NET_LOG_MAX_BYTES = 32 * 1024 * 1024;
364
+ let netLogDir = null;
365
+ let netLogLastRotate = 0;
366
+ function netLogDirectory() {
367
+ if (netLogDir === null) {
368
+ netLogDir = path.join(os.homedir(), '.zames', 'net-log');
369
+ }
370
+ return netLogDir;
371
+ }
372
+ /** A stamp prefix (`YYYY-MM-DDTHH-MM-SS-mmmZ_`) sorts chronologically. */
373
+ function netLogStamp() {
374
+ return new Date().toISOString().replace(/[:.]/g, '-');
375
+ }
355
376
  export async function dumpNetBody(url, body) {
356
377
  if (!process.env.ZAMES_NET_DEBUG)
357
378
  return;
358
379
  try {
359
- const dir = path.join(os.homedir(), '.zames', 'net-log');
380
+ const dir = netLogDirectory();
360
381
  await fs.mkdir(dir, { recursive: true });
361
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
362
382
  const safe = url.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80);
363
- await fs.writeFile(path.join(dir, stamp + '_' + safe + '.txt'), body, 'utf-8');
383
+ await fs.writeFile(path.join(dir, netLogStamp() + '_' + safe + '.txt'), body.slice(0, NET_LOG_MAX_BYTES), 'utf-8');
384
+ await rotateNetLogThrottled(dir);
364
385
  }
365
386
  catch { }
366
387
  }
388
+ /**
389
+ * Keeps only the newest `NET_LOG_KEEP` dumps. Exported for the test: the
390
+ * rotation is the only thing standing between `ZAMES_NET_DEBUG=1` and an
391
+ * unbounded directory, so it is checked directly.
392
+ */
393
+ export async function rotateNetLog(dir, keep = NET_LOG_KEEP, now = Date.now()) {
394
+ // The stamp prefix sorts the names chronologically, so a plain sort is
395
+ // enough — no stat() per file (a directory with 77k entries would make
396
+ // 77k syscalls on every response).
397
+ const names = (await fs.readdir(dir).catch(() => [])).filter((n) => n.endsWith('.txt'));
398
+ if (names.length <= keep)
399
+ return 0;
400
+ names.sort();
401
+ const stale = names.slice(0, names.length - keep);
402
+ let removed = 0;
403
+ for (const n of stale) {
404
+ await fs
405
+ .rm(path.join(dir, n), { force: true })
406
+ .then(() => removed++)
407
+ .catch(() => { });
408
+ }
409
+ netLogLastRotate = now;
410
+ return removed;
411
+ }
412
+ /** Throttled rotation used by dumpNetBody (at most once per interval). */
413
+ async function rotateNetLogThrottled(dir) {
414
+ const now = Date.now();
415
+ if (now - netLogLastRotate < NET_LOG_ROTATE_MS)
416
+ return;
417
+ netLogLastRotate = now;
418
+ await rotateNetLog(dir, NET_LOG_KEEP, now);
419
+ }
@@ -11,9 +11,20 @@ import fs from 'fs';
11
11
  import path from 'path';
12
12
  import os from 'os';
13
13
  import { writeJsonAtomic } from './fsutil.js';
14
+ import { providerLabel } from './providers.js';
14
15
  const ZAMES_HOME = path.join(os.homedir(), '.zames');
15
16
  const HANDOFF_FILE = path.join(ZAMES_HOME, 'provider-handoff.json');
16
17
  const NL = String.fromCharCode(10);
18
+ /**
19
+ * The DISPLAY label of the provider a handoff came from. Prefers the explicit
20
+ * `from` field; a legacy file (written when exactly two backends existed) has
21
+ * none, and there "the other one" is the only correct answer. PURE.
22
+ */
23
+ export function handoffFromLabel(h) {
24
+ if (h.from)
25
+ return providerLabel(h.from);
26
+ return h.provider === 'qwen' ? 'DeepSeek' : 'Qwen';
27
+ }
17
28
  /**
18
29
  * Wrap a cross-provider summary into the first message of the new provider's
19
30
  * chat. PURE (unit-tested): the wording must make clear this is carried-over
package/dist/providers.js CHANGED
@@ -8,20 +8,55 @@
8
8
  // /doctor row, the {provider} label in operator messages, /chat URL) reads
9
9
  // from HERE, so nothing else has to be touched.
10
10
  //
11
+ // CAPABILITY FLAGS: the code used to ask `provider === 'qwen'` in a dozen
12
+ // places, which made "there are exactly two backends" a silent assumption and
13
+ // broke the moment a third one arrived. The flags below are that knowledge,
14
+ // stated once per provider, so every call site asks a CAPABILITY instead.
15
+ //
11
16
  // This module is PURE (no imports, no side effects) so it can be imported from
12
17
  // anywhere without pulling in Playwright.
18
+ import path from 'path';
19
+ import os from 'os';
20
+ /** `~/.zames/<dir>` — the per-provider profile location. */
21
+ function zamesProfile(dir) {
22
+ return path.join(os.homedir(), '.zames', dir);
23
+ }
13
24
  export const PROVIDERS = {
14
25
  deepseek: {
15
26
  id: 'deepseek',
16
27
  label: 'DeepSeek',
17
28
  chatUrlPrefix: 'https://chat.deepseek.com/a/chat/s',
29
+ chatUrl: 'https://chat.deepseek.com/',
30
+ chatIdPattern: '/a/chat/s/([A-Za-z0-9_-]+)',
18
31
  isDefault: true,
32
+ hasModelPicker: false,
33
+ guestAllowed: false,
34
+ profileDir: zamesProfile('profile'),
35
+ needsVisibleBrowser: false,
19
36
  },
20
37
  qwen: {
21
38
  id: 'qwen',
22
39
  label: 'Qwen',
23
40
  chatUrlPrefix: 'https://chat.qwen.ai/c',
41
+ chatUrl: 'https://chat.qwen.ai/',
42
+ chatIdPattern: '/c/([A-Za-z0-9_-]+)',
43
+ isDefault: false,
44
+ hasModelPicker: true,
45
+ guestAllowed: true,
46
+ profileDir: zamesProfile('qwen-profile'),
47
+ needsVisibleBrowser: false,
48
+ },
49
+ glm: {
50
+ id: 'glm',
51
+ label: 'GLM',
52
+ chatUrlPrefix: 'https://chat.z.ai/c',
53
+ chatUrl: 'https://chat.z.ai/',
54
+ chatIdPattern: '/c/([A-Za-z0-9_-]+)',
24
55
  isDefault: false,
56
+ hasModelPicker: true,
57
+ guestAllowed: true,
58
+ profileDir: zamesProfile('glm-profile'),
59
+ needsVisibleBrowser: true,
25
60
  },
26
61
  };
27
62
  /** All provider ids, in a stable order (default first). */
@@ -48,3 +83,33 @@ export function providerLabel(id) {
48
83
  export function providerChatUrlPrefix(id) {
49
84
  return isProviderId(id) ? PROVIDERS[id].chatUrlPrefix : '';
50
85
  }
86
+ /** The provider's config section name (`qwen`, `glm`, …) or null for DeepSeek. */
87
+ export function providerConfigSection(id) {
88
+ // DeepSeek's shared settings live in `browser`; the other backends own a
89
+ // per-provider section (qwen.*, glm.*).
90
+ return id === 'deepseek' ? null : id;
91
+ }
92
+ /** True when the provider exposes a model picker (`/model`). */
93
+ export function providerHasModelPicker(id) {
94
+ return isProviderId(id) ? PROVIDERS[id].hasModelPicker : false;
95
+ }
96
+ /** True when an unsigned (guest) visitor is usable on the site. */
97
+ export function providerGuestAllowed(id) {
98
+ return isProviderId(id) ? PROVIDERS[id].guestAllowed : false;
99
+ }
100
+ /** The persistent profile directory of a provider. */
101
+ /** True when the provider needs a VISIBLE browser (a headless captcha). */
102
+ export function providerNeedsVisibleBrowser(id) {
103
+ return isProviderId(id) ? PROVIDERS[id].needsVisibleBrowser : false;
104
+ }
105
+ export function providerProfileDir(id) {
106
+ return isProviderId(id) ? PROVIDERS[id].profileDir : '';
107
+ }
108
+ /** The chat home URL of a provider. */
109
+ export function providerChatUrl(id) {
110
+ return isProviderId(id) ? PROVIDERS[id].chatUrl : '';
111
+ }
112
+ /** A provider's chat-id matcher (compiled from the registry pattern). */
113
+ export function providerChatIdRe(id) {
114
+ return new RegExp(PROVIDERS[id].chatIdPattern);
115
+ }
@@ -21,10 +21,11 @@ import os from 'os';
21
21
  import { chromium } from 'playwright';
22
22
  import { translate, DEFAULT_LOCALE } from './i18n.js';
23
23
  import { rewriteFailedAttachments } from './commands.js';
24
+ import { interactiveBrowserLogin, loginIsInteractive, canShowWindow, } from './browser-login.js';
24
25
  import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
25
26
  import { theme } from './theme.js';
26
27
  import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
27
- import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, AUTH_MARKER_FILE, } from './browser-pure.js';
28
+ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, writeAuthMarker, } from './browser-pure.js';
28
29
  import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
29
30
  import { probeUiHealth, } from './ui-health.js';
30
31
  import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
@@ -122,7 +123,10 @@ export class QwenBrowser {
122
123
  onSendState;
123
124
  onNotice;
124
125
  userAgent;
125
- constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, userAgent, } = {}) {
126
+ loginTimeoutMs;
127
+ _windowClosed;
128
+ _carryCookies;
129
+ constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, onLoginPrompt, loginTimeoutMs = 600000, userAgent, } = {}) {
126
130
  this.headless = headless;
127
131
  this.debug = debug;
128
132
  this.channel = channel;
@@ -178,6 +182,9 @@ export class QwenBrowser {
178
182
  this.onSendState = null;
179
183
  this.onNotice = null;
180
184
  this.userAgent = userAgent || '';
185
+ this.loginTimeoutMs = loginTimeoutMs;
186
+ this._windowClosed = false;
187
+ this._carryCookies = null;
181
188
  }
182
189
  _t(key, params) {
183
190
  return translate(this.locale)(key, { provider: 'Qwen', ...params });
@@ -221,7 +228,7 @@ export class QwenBrowser {
221
228
  await cleanSingletonFiles(QWEN_USER_DATA_DIR);
222
229
  }
223
230
  if (this.headless && !this.userAgent) {
224
- const cached = await readCachedHeadlessUA(headlessEngineKey() + ':qwen');
231
+ const cached = await readCachedHeadlessUA(headlessEngineKey());
225
232
  if (cached)
226
233
  this.userAgent = cached;
227
234
  }
@@ -242,7 +249,7 @@ export class QwenBrowser {
242
249
  if (fixed === ua)
243
250
  return;
244
251
  this.userAgent = fixed;
245
- void writeCachedHeadlessUA(headlessEngineKey() + ':qwen', fixed);
252
+ void writeCachedHeadlessUA(headlessEngineKey(), fixed);
246
253
  if (this.debug)
247
254
  console.error('qwen profile: headless UA -> relaunching plain Chrome UA');
248
255
  await this.context.close().catch(() => { });
@@ -341,6 +348,30 @@ export class QwenBrowser {
341
348
  catch { }
342
349
  }
343
350
  async close() {
351
+ // A session cookie (expires = -1) is dropped when the context closes, so a
352
+ // sign-in would not survive the process. Re-add it as a PERSISTENT cookie
353
+ // before closing: the token then lives in the profile and the next launch
354
+ // is signed in without a window.
355
+ try {
356
+ if (this.context) {
357
+ const cookies = await this.context.cookies();
358
+ const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
359
+ if (session.length) {
360
+ const keepUntil = Math.floor((Date.now() + 15552000000) / 1000);
361
+ await this.context.addCookies(session.map((c) => ({
362
+ name: c.name,
363
+ value: c.value,
364
+ domain: c.domain,
365
+ path: c.path,
366
+ httpOnly: c.httpOnly,
367
+ secure: c.secure,
368
+ sameSite: c.sameSite,
369
+ expires: keepUntil,
370
+ })));
371
+ }
372
+ }
373
+ }
374
+ catch { }
344
375
  try {
345
376
  if (this.context)
346
377
  await this.context.close();
@@ -481,41 +512,245 @@ export class QwenBrowser {
481
512
  }
482
513
  return false;
483
514
  }
484
- async waitForLogin() {
485
- if (await this.isLoggedIn()) {
486
- if (this.debug)
487
- console.error('qwen auth: session already active');
488
- await this._persistSessionIfNeeded();
489
- return;
515
+ /**
516
+ * A FAST login probe: Qwen's own `auths/refresh` API needs the app's header,
517
+ * so it cannot be replayed from a bare fetch. Instead the page reports
518
+ * whether a token is in storage and whether a sign-in control is visible. A
519
+ * guest ALSO sees the input, so the input alone must never mean "signed in".
520
+ */
521
+ async probeLogin() {
522
+ if (!this.page)
523
+ return 'unknown';
524
+ try {
525
+ const r = await this.page.evaluate(() => {
526
+ const hasToken = (() => {
527
+ try {
528
+ return !!localStorage.getItem('token');
529
+ }
530
+ catch {
531
+ return false;
532
+ }
533
+ })();
534
+ const body = document.body ? document.body.innerText : '';
535
+ const signInVisible = /\b(log ?in|sign ?in)\b/i.test(body);
536
+ return { hasToken, signInVisible };
537
+ });
538
+ if (r.hasToken)
539
+ return 'in';
540
+ if (r.signInVisible)
541
+ return 'out';
542
+ return 'unknown';
490
543
  }
491
- // No stored session: Qwen still answers a GUEST for a limited number of
492
- // turns, but the agent is multi-turn by nature, so a real login is
493
- // strongly recommended. Try the saved credentials first, then ask the
494
- // operator in a TTY.
495
- if (this.auth.username) {
496
- if (await this._autoLogin()) {
497
- await this._persistSessionIfNeeded();
498
- return;
499
- }
544
+ catch {
545
+ return 'unknown';
546
+ }
547
+ }
548
+ /**
549
+ * A human signs in inside a VISIBLE window (a one-time code cannot be typed
550
+ * into a terminal), then zames returns to headless. The decision sequence is
551
+ * the shared interactiveBrowserLogin.
552
+ */
553
+ async _browserLogin() {
554
+ const interactive = loginIsInteractive({
555
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
556
+ oneShot: false,
557
+ scheduled: false,
558
+ env: process.env,
559
+ });
560
+ await interactiveBrowserLogin({
561
+ provider: 'Qwen',
562
+ probeLogin: () => this.probeLogin(),
563
+ autoLogin: () => this._autoLogin(),
564
+ relaunch: (h) => this.relaunchSafe(h),
565
+ openLoginPage: () => this._openLoginPage(),
566
+ gotoChat: async () => {
567
+ await this.page
568
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
569
+ .catch(() => { });
570
+ },
571
+ prefill: async () => {
572
+ try {
573
+ if (!this.auth.username)
574
+ return;
575
+ const email = this.page
576
+ .locator('input[name="email"], input[type="email"]')
577
+ .first();
578
+ if ((await email.count().catch(() => 0)) === 0)
579
+ return;
580
+ if (await email.isVisible().catch(() => false)) {
581
+ await email
582
+ .fill(this.auth.username, { timeout: 2000 })
583
+ .catch(() => { });
584
+ }
585
+ }
586
+ catch { }
587
+ },
588
+ captureCredentials: async () => {
589
+ try {
590
+ await this.page.exposeFunction('__zamesQwenCreds', (u) => {
591
+ const user = String(u || '').trim();
592
+ if (!user)
593
+ return;
594
+ this.auth.username = user;
595
+ try {
596
+ this.onAuthSave?.(user, '');
597
+ }
598
+ catch { }
599
+ });
600
+ await this.page.evaluate(() => {
601
+ const w = window;
602
+ if (w.__zamesQwenHooked)
603
+ return;
604
+ w.__zamesQwenHooked = true;
605
+ document.addEventListener('submit', (ev) => {
606
+ try {
607
+ const form = ev.target;
608
+ if (!form)
609
+ return;
610
+ const e = form.querySelector('input[type="email"], input[name="email"]');
611
+ const fn = window.__zamesQwenCreds;
612
+ if (fn && e)
613
+ fn(e.value || '');
614
+ }
615
+ catch { }
616
+ }, true);
617
+ });
618
+ }
619
+ catch { }
620
+ },
621
+ persistSession: () => this._persistSessionIfNeeded(),
622
+ notice: (key) => this._notice(this._t(key)),
623
+ isAborted: () => this._abort,
624
+ windowClosed: () => this._windowClosed,
625
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
626
+ now: () => Date.now(),
627
+ interactive,
628
+ stayHeaded: !this.headless,
629
+ guestAllowed: true,
630
+ canShowWindow: canShowWindow(process.env, process.platform),
631
+ tuiLogin: this.onLoginPrompt
632
+ ? async () => {
633
+ return await this._promptAndLogin();
634
+ }
635
+ : undefined,
636
+ timeoutMs: this.loginTimeoutMs,
637
+ });
638
+ }
639
+ /** Relaunch in another headless mode (login flow), keeping session cookies. */
640
+ async relaunchSafe(headless) {
641
+ try {
642
+ const cookies = await this.context.cookies();
643
+ const session = cookies.filter((c) => c.expires === -1 || c.expires < 0);
644
+ if (session.length)
645
+ this._carryCookies = session;
500
646
  }
501
- if (process.stdin.isTTY) {
502
- await this._promptAndLogin();
647
+ catch { }
648
+ try {
649
+ if (this.context)
650
+ await this.context.close();
503
651
  }
504
- else {
505
- this._notice(this._t('qwen.guest_mode'));
652
+ catch { }
653
+ const deadline = Date.now() + 15000;
654
+ while (Date.now() < deadline) {
655
+ if (!(await profileLooksLocked(QWEN_USER_DATA_DIR)))
656
+ break;
657
+ await new Promise((r) => setTimeout(r, 200));
658
+ }
659
+ this.headless = headless;
660
+ const options = {
661
+ headless,
662
+ slowMo: 10,
663
+ args: ['--disable-blink-features=AutomationControlled'],
664
+ };
665
+ if (this.channel)
666
+ options.channel = this.channel;
667
+ if (this.userAgent)
668
+ options.userAgent = this.userAgent;
669
+ try {
670
+ this.context = await chromium.launchPersistentContext(QWEN_USER_DATA_DIR, options);
671
+ }
672
+ catch (e) {
673
+ throw new Error('qwen: the browser profile is busy (' + e.message + ')');
674
+ }
675
+ if (this._carryCookies && this._carryCookies.length) {
676
+ await this.context.addCookies(this._carryCookies).catch(() => { });
506
677
  }
507
- await this._persistSessionIfNeeded();
678
+ this.page = this.context.pages()[0] || (await this.context.newPage());
679
+ this._windowClosed = false;
680
+ this.page.on('close', () => {
681
+ this._windowClosed = true;
682
+ });
683
+ this.context.on('close', () => {
684
+ this._windowClosed = true;
685
+ });
686
+ this._netHookInstalled = false;
687
+ this._netHookPage = null;
688
+ this._installNetHook();
689
+ }
690
+ async waitForLogin() {
691
+ // An existing session, then the saved credentials, then (Qwen allows a
692
+ // guest, so no error) the guest notice. The visible window and the return
693
+ // to headless are the SHARED interactiveBrowserLogin path.
694
+ const interactive = loginIsInteractive({
695
+ isTTY: !!(process.stdin.isTTY && process.stdout.isTTY),
696
+ oneShot: false,
697
+ scheduled: false,
698
+ env: process.env,
699
+ });
700
+ const outcome = await interactiveBrowserLogin({
701
+ provider: 'Qwen',
702
+ probeLogin: () => this.probeLogin(),
703
+ autoLogin: () => this._autoLogin(),
704
+ relaunch: (h) => this.relaunchSafe(h),
705
+ openLoginPage: () => this._openLoginPage(),
706
+ gotoChat: async () => {
707
+ await this.page
708
+ .goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 })
709
+ .catch(() => { });
710
+ },
711
+ prefill: async () => {
712
+ try {
713
+ if (!this.auth.username)
714
+ return;
715
+ const email = this.page
716
+ .locator('input[name="email"], input[type="email"]')
717
+ .first();
718
+ if ((await email.count().catch(() => 0)) === 0)
719
+ return;
720
+ if (await email.isVisible().catch(() => false)) {
721
+ await email.fill(this.auth.username).catch(() => { });
722
+ }
723
+ }
724
+ catch { }
725
+ },
726
+ captureCredentials: async () => { },
727
+ persistSession: () => this._persistSessionIfNeeded(),
728
+ notice: (key) => this._notice(this._t(key)),
729
+ isAborted: () => this._abort,
730
+ windowClosed: () => this._windowClosed,
731
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
732
+ now: () => Date.now(),
733
+ interactive,
734
+ stayHeaded: !this.headless,
735
+ // A guest is usable, but a real session is strongly recommended; the
736
+ // notice tells the operator why.
737
+ guestAllowed: true,
738
+ canShowWindow: canShowWindow(process.env, process.platform),
739
+ tuiLogin: this.onLoginPrompt
740
+ ? async () => await this._promptAndLogin()
741
+ : undefined,
742
+ timeoutMs: this.loginTimeoutMs,
743
+ });
744
+ if (outcome === 'guest')
745
+ this._notice(this._t('qwen.guest_mode'));
746
+ await this._persistSessionIfNeeded().catch(() => { });
508
747
  }
509
748
  async _persistSessionIfNeeded() {
510
749
  if (!this.auth.saveSession)
511
750
  return;
512
751
  if (!(await this.isLoggedIn()))
513
752
  return;
514
- try {
515
- await fs.mkdir(path.dirname(AUTH_MARKER_FILE), { recursive: true });
516
- await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ provider: 'qwen', at: new Date().toISOString() }), 'utf-8');
517
- }
518
- catch { }
753
+ await writeAuthMarker('qwen');
519
754
  }
520
755
  // Best-effort PASSWORD login for the saved credentials. Qwen's default form
521
756
  // is the email CODE flow, so this first switches to the password form (the
@@ -548,8 +783,17 @@ export class QwenBrowser {
548
783
  await pwd.fill(this.auth.password);
549
784
  await this._clickSubmit();
550
785
  const ok = await this._waitLoggedIn(20000);
551
- if (ok)
786
+ if (ok) {
552
787
  this._notice(this._t('qwen.login_ok'));
788
+ // Remember the credentials that WORKED, so the next launch signs in
789
+ // silently without a window. The auto-login is the one path that used
790
+ // to skip this, which is why a successful auto sign-in left the
791
+ // config without the password and the NEXT cold start needed a window.
792
+ try {
793
+ this.onAuthSave?.(this.auth.username, this.auth.password);
794
+ }
795
+ catch { }
796
+ }
553
797
  return ok;
554
798
  }
555
799
  catch (e) {
@@ -0,0 +1,112 @@
1
+ // Per-browser SEND RUNTIME: telemetry + the adaptive throttle, wired in ONE
2
+ // place so each provider class only forwards its send events here.
3
+ //
4
+ // WHY a helper instead of per-class code: the three backends (DeepSeek, Qwen,
5
+ // GLM) share the policy but differ in where a send starts and how an error is
6
+ // classified. Keeping the state machine here means one implementation to test
7
+ // and one place to look at when "the agent waits too long" is reported.
8
+ //
9
+ // The state lives ON the browser object (a SendRuntime instance), so it
10
+ // survives across tasks and a provider switch within a session (a new browser
11
+ // object is created, but the operator's decision about the interval does not
12
+ // silently reset mid-session — it is re-derived from this session's events).
13
+ import { DEFAULT_THROTTLE, initialThrottleState, applySendEvent, nextWaitMs, withBlockDuration, } from './send-throttle.js';
14
+ import { appendSendEvent, } from './send-telemetry.js';
15
+ /**
16
+ * The runtime a provider keeps: the learned interval, the last send and the
17
+ * bookkeeping needed to record the outcome of the NEXT send.
18
+ */
19
+ export class SendRuntime {
20
+ cfg;
21
+ state;
22
+ opts;
23
+ now;
24
+ lastSentAt = 0;
25
+ pending = null;
26
+ constructor(opts) {
27
+ this.opts = opts;
28
+ this.now = opts.now || Date.now;
29
+ this.cfg = {
30
+ baseMs: opts.baseMs,
31
+ rateLimitWaitMs: opts.rateLimitWaitMs,
32
+ ...DEFAULT_THROTTLE,
33
+ windowMs: opts.windowMs ?? DEFAULT_THROTTLE.windowMs,
34
+ budget: opts.budget ?? DEFAULT_THROTTLE.budget,
35
+ };
36
+ this.state = initialThrottleState(this.cfg);
37
+ }
38
+ /** The interval currently in force (the adaptive one when enabled). */
39
+ intervalMs() {
40
+ return this.opts.adaptive ? this.state.intervalMs : this.opts.baseMs;
41
+ }
42
+ /** Milliseconds to wait before the next send. */
43
+ waitMs() {
44
+ if (!this.opts.adaptive) {
45
+ const gap = this.opts.baseMs - (this.now() - this.lastSentAt);
46
+ return this.lastSentAt && gap > 0 ? gap : 0;
47
+ }
48
+ return nextWaitMs(this.state, this.lastSentAt, this.now());
49
+ }
50
+ /**
51
+ * Mark a send as STARTED. `model`/`thinking`/`webSearch` are recorded with
52
+ * the outcome, so a later analysis can correlate a limit with a mode.
53
+ */
54
+ onSend(info = {}) {
55
+ const waited = this.lastSentAt ? this.now() - this.lastSentAt : 0;
56
+ this.lastSentAt = this.now();
57
+ this.pending = {
58
+ at: new Date(this.lastSentAt).toISOString(),
59
+ provider: this.opts.provider,
60
+ model: info.model,
61
+ thinking: info.thinking,
62
+ webSearch: info.webSearch,
63
+ intervalMs: this.intervalMs(),
64
+ waitedMs: waited,
65
+ outcome: 'ok',
66
+ };
67
+ }
68
+ /**
69
+ * Record the outcome of the send started by onSend(). `errorText` and
70
+ * `retryAfterMs` come from the server and never include the prompt.
71
+ */
72
+ onOutcome(outcome, extra = {}) {
73
+ if (!this.pending)
74
+ return;
75
+ let e = { ...this.pending, outcome };
76
+ if (extra.errorText)
77
+ e.errorText = String(extra.errorText).slice(0, 300);
78
+ if (typeof extra.retryAfterMs === 'number') {
79
+ e.retryAfterMs = extra.retryAfterMs;
80
+ }
81
+ if (this.opts.adaptive) {
82
+ this.state = applySendEvent(this.state, e, this.cfg, this.now());
83
+ }
84
+ if (outcome === 'rate_limit') {
85
+ e = withBlockDuration(e, this.state.blockedAt, this.now());
86
+ }
87
+ if (this.opts.telemetry) {
88
+ appendSendEvent(this.opts.logsDir, e);
89
+ }
90
+ this.pending = null;
91
+ }
92
+ /**
93
+ * The block lifted (the next send succeeded after a rate limit): the
94
+ * telemetry gets the measured duration.
95
+ */
96
+ onBlockLifted() {
97
+ if (!this.state.blockedAt)
98
+ return;
99
+ const liftedAt = this.now();
100
+ if (this.opts.telemetry) {
101
+ appendSendEvent(this.opts.logsDir, {
102
+ at: new Date(liftedAt).toISOString(),
103
+ provider: this.opts.provider,
104
+ intervalMs: this.intervalMs(),
105
+ waitedMs: 0,
106
+ outcome: 'ok',
107
+ blockMs: liftedAt - this.state.blockedAt,
108
+ });
109
+ }
110
+ this.state = { ...this.state, blocked: false, blockedAt: 0 };
111
+ }
112
+ }