zames_pro 2.16.0 → 2.17.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/README.md CHANGED
@@ -9,7 +9,42 @@ directory, reads and edits files, runs commands, and commits to git.
9
9
  ## Requirements
10
10
 
11
11
  - Node.js >= 18
12
- - A DeepSeek account (you log in manually in the browser window that opens on first launch)
12
+ - A DeepSeek account. On first launch zames asks for your DeepSeek
13
+ login/password in the terminal (and stores them in `~/.zames/config.json`
14
+ after a successful sign-in, so a later logout is handled automatically
15
+ without asking you again). You can also sign in manually in the browser
16
+ window when the browser is headed.
17
+
18
+ ## Signing in
19
+
20
+ The browser runs **headless by default**. When DeepSeek requires a sign-in,
21
+ zames:
22
+
23
+ 1. reuses the session stored in the persistent profile (`~/.zames/profile`)
24
+ if it is still valid;
25
+ 2. otherwise signs in automatically with the saved credentials
26
+ (`browser.auth.username` / `browser.auth.password`);
27
+ 3. otherwise asks you for the login/password in the terminal (in a TTY) and,
28
+ after a successful sign-in, remembers them for next time;
29
+ 4. otherwise falls back to a manual sign-in hint.
30
+
31
+ To sign in by hand (for example, if DeepSeek shows a captcha), run with a
32
+ visible window:
33
+
34
+ ```bash
35
+ zames --headed
36
+ ```
37
+
38
+ The `--headless` flag (the default) and `headless: true` in the config keep
39
+ the browser without a window; `--headed` / `headless: false` show it.
40
+
41
+ Headless works out of the box: a headless Chrome normally advertises a
42
+ `HeadlessChrome/...` User-Agent that DeepSeek's CDN blocks with a 403, so
43
+ zames strips that marker before loading the page (keeping the real engine
44
+ version). You do not need `--headed` just to log in.
45
+
46
+ Credentials and toggles can also be edited from `/config`
47
+ (`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
13
48
 
14
49
  ## Installation
15
50
 
@@ -71,6 +106,7 @@ zames --new-chat
71
106
  zames --resend-prompt
72
107
  zames --dir <path>
73
108
  zames --headless
109
+ zames --headed
74
110
  zames --debug
75
111
  zames --version
76
112
  zames --help
@@ -4,7 +4,7 @@ import { getGitContext, formatGitContext } from './gitTools.js';
4
4
  import { parseXmlToolCalls } from './xml-toolcall.js';
5
5
  import { translate } from './i18n.js';
6
6
  import { normText } from './browser.js';
7
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
7
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
8
8
  // UI callbacks must NEVER break the agent loop. A rendering error (a huge
9
9
  // tool result, a broken markdown frame, a closed terminal) used to throw
10
10
  // out of the loop right after a tool call — the session looked "stopped
@@ -26,6 +26,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
26
26
  // Previously safeThinking() fired before browser.ask(), so the spinner ran
27
27
  // through the whole throttle pause / chat-open phase with nothing in flight.
28
28
  browser.onSendStart = safeThinking;
29
+ // Animate the send-interval pause too: the browser reports the remaining
30
+ // seconds, the UI shows an animated status instead of a frozen line.
31
+ const safeSendPause = safe(onSendPause);
32
+ browser.onSendPause = safeSendPause;
29
33
  const safeAssistantThought = safe(onAssistantThought);
30
34
  const safeToolCall = safe(onToolCall);
31
35
  const safeToolResult = safe(onToolResult);
package/dist/browser.js CHANGED
@@ -5,8 +5,134 @@ import os from 'os';
5
5
  import fs from 'fs/promises';
6
6
  import { execSync } from 'child_process';
7
7
  import { theme } from './theme.js';
8
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
9
+ // Muted terminal input for passwords. We do NOT use readline here: readline
10
+ // echoes the typed text through its own internal _writeToOutput, which cannot
11
+ // be reliably overridden from outside (a real bug: the login prompt swallowed
12
+ // the password step). Instead we read raw keystrokes from stdin and echo one
13
+ // `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
14
+ // plain line read.
15
+ export function askPassword(question) {
16
+ return new Promise((resolve) => {
17
+ process.stdout.write(question);
18
+ // Non-interactive: read a single line from stdin as-is.
19
+ if (!process.stdin.isTTY) {
20
+ void import('readline').then(({ createInterface }) => {
21
+ const rl = createInterface({
22
+ input: process.stdin,
23
+ output: process.stdout,
24
+ });
25
+ rl.question('', (answer) => {
26
+ rl.close();
27
+ resolve(answer);
28
+ });
29
+ });
30
+ return;
31
+ }
32
+ const stdin = process.stdin;
33
+ const wasRaw = stdin.isRaw;
34
+ stdin.setRawMode(true);
35
+ stdin.resume();
36
+ let value = '';
37
+ const onData = (buf) => {
38
+ const s = buf.toString('utf8');
39
+ for (const ch of s) {
40
+ const code = ch.charCodeAt(0);
41
+ // Enter / Ctrl+J -> done.
42
+ if (ch === '\r' || ch === '\n') {
43
+ done();
44
+ return;
45
+ }
46
+ // Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
47
+ if (code === 3) {
48
+ cleanup();
49
+ process.stdout.write('\n');
50
+ process.exit(130);
51
+ }
52
+ // Ctrl+D -> finish with whatever we have.
53
+ if (code === 4) {
54
+ done();
55
+ return;
56
+ }
57
+ // Backspace (0x7f or 0x08): remove the last char and one star.
58
+ if (code === 127 || code === 8) {
59
+ if (value.length > 0) {
60
+ value = value.slice(0, -1);
61
+ process.stdout.write('\b \b');
62
+ }
63
+ continue;
64
+ }
65
+ // Ignore other control characters (arrows, etc.).
66
+ if (code < 32)
67
+ continue;
68
+ value += ch;
69
+ process.stdout.write('*');
70
+ }
71
+ };
72
+ const cleanup = () => {
73
+ stdin.removeListener('data', onData);
74
+ try {
75
+ stdin.setRawMode(wasRaw || false);
76
+ }
77
+ catch { }
78
+ stdin.pause();
79
+ };
80
+ const done = () => {
81
+ cleanup();
82
+ process.stdout.write('\n');
83
+ resolve(value);
84
+ };
85
+ stdin.on('data', onData);
86
+ });
87
+ }
8
88
  const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
9
89
  const CHAT_URL = 'https://chat.deepseek.com/';
90
+ // A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
91
+ // DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
92
+ // page before the app is even served — so a headless login looked broken while
93
+ // a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
94
+ // engine version and only drop the "Headless" marker. When the operator
95
+ // explicitly set a UA in the config, that one wins (see _launchOnce).
96
+ export function sanitizeHeadlessUA(ua) {
97
+ return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
98
+ }
99
+ // Written after a successful sign-in so the next launch knows a session was
100
+ // stored in the persistent profile (used by /doctor and diagnostics).
101
+ const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
102
+ // DeepSeek sign-in form. The login page is served on the same origin and
103
+ // swaps in a password field; the exact classes change, so we match loosely on
104
+ // input types and the known placeholder/autocomplete attributes.
105
+ const PASSWORD_SELECTORS = [
106
+ 'input[type="password"]',
107
+ 'input[autocomplete="current-password"]',
108
+ 'input[autocomplete="new-password"]',
109
+ ];
110
+ const LOGIN_SELECTORS = [
111
+ 'input[type="email"]',
112
+ 'input[type="tel"]',
113
+ 'input[name="email"]',
114
+ 'input[name="phone"]',
115
+ 'input[name="username"]',
116
+ 'input[placeholder*="email" i]',
117
+ 'input[placeholder*="phone" i]',
118
+ 'input[placeholder*="телефон" i]',
119
+ 'input[placeholder*="почт" i]',
120
+ 'input[autocomplete="username"]',
121
+ 'input[autocomplete="email"]',
122
+ ];
123
+ const LOGIN_SUBMIT_SELECTORS = [
124
+ // DeepSeek's real button: a div[role=button] with these classes and a
125
+ // <span class="ds-button__content">Log in</span> inside.
126
+ 'div[role="button"].ds-button--primary.ds-button--filled',
127
+ 'div[role="button"].ds-button--primary',
128
+ 'button[type="submit"]',
129
+ 'button:has-text("Log in")',
130
+ 'button:has-text("Sign in")',
131
+ 'button:has-text("Войти")',
132
+ 'div[role="button"]:has-text("Log in")',
133
+ 'div[role="button"]:has-text("Sign in")',
134
+ 'div[role="button"]:has-text("Войти")',
135
+ ];
10
136
  const INPUT_SELECTORS = [
11
137
  'textarea',
12
138
  'div[contenteditable="true"]',
@@ -130,6 +256,16 @@ export class DeepSeekBrowser {
130
256
  // is never read/shown). webSearch: the "Smart search" toggle.
131
257
  deepThinking;
132
258
  webSearch;
259
+ // Credentials for automatic sign-in. The password is stored in the config
260
+ // file (~/.zames/config.json) and is used only when DeepSeek has logged the
261
+ // session out. `saveSession` keeps the profile (cookies) so a fresh launch
262
+ // reuses the session instead of asking the operator every time.
263
+ auth;
264
+ // Interface language for the login prompts printed by the browser itself.
265
+ locale;
266
+ // Called after a successful interactive login so index.ts can persist the
267
+ // credentials into the user config (the browser does not write config).
268
+ onAuthSave;
133
269
  _lastSentAt;
134
270
  _abort;
135
271
  // The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
@@ -149,7 +285,15 @@ export class DeepSeekBrowser {
149
285
  // a generation really begins, not during the pre-send phase (chat open,
150
286
  // throttle wait), which used to show a spinner with no work in flight.
151
287
  onSendStart;
152
- constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, deepThinking = false, webSearch = true, } = {}) {
288
+ // Fired when the agent starts waiting out the send-interval pause, with the
289
+ // remaining seconds. Lets the UI animate the pause status instead of
290
+ // printing a static line (the dots used to be frozen during the pause).
291
+ onSendPause;
292
+ // User-Agent sent by the browser. In headless mode the "Headless" marker
293
+ // must be stripped (DeepSeek's CDN answers 403 to it); computed in the
294
+ // constructor, overridable by an explicit UA in the options/config.
295
+ userAgent;
296
+ constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
153
297
  this.headless = headless;
154
298
  this.debug = debug;
155
299
  this.channel = channel;
@@ -164,6 +308,13 @@ export class DeepSeekBrowser {
164
308
  this.serverBusyWaitMs = serverBusyWaitMs;
165
309
  this.deepThinking = deepThinking;
166
310
  this.webSearch = webSearch;
311
+ this.auth = {
312
+ username: (auth?.username || '').trim(),
313
+ password: auth?.password || '',
314
+ saveSession: auth?.saveSession !== false,
315
+ };
316
+ this.locale = locale || DEFAULT_LOCALE;
317
+ this.onAuthSave = onAuthSave || null;
167
318
  this._lastSentAt = 0;
168
319
  this._abort = false;
169
320
  this._stopped = false;
@@ -174,6 +325,16 @@ export class DeepSeekBrowser {
174
325
  this._netSniffLimit = 5;
175
326
  this._netHookInstalled = false;
176
327
  this.onSendStart = null;
328
+ this.onSendPause = null;
329
+ // An explicit UA (config/options) is respected as-is. Otherwise it stays
330
+ // empty here and is derived from the real engine UA after launch — see
331
+ // _fixHeadlessUserAgent (a headless "HeadlessChrome/..." UA gets 403 from
332
+ // DeepSeek's CDN).
333
+ this.userAgent = userAgent ? sanitizeHeadlessUA(userAgent) : '';
334
+ }
335
+ /** Translation bound to this browser's locale. */
336
+ _t(key, params) {
337
+ return translate(this.locale)(key, params);
177
338
  }
178
339
  async launch() {
179
340
  await killStaleChrome();
@@ -183,6 +344,35 @@ export class DeepSeekBrowser {
183
344
  await cleanSingletonFiles();
184
345
  }
185
346
  await this._launchOnce();
347
+ await this._fixHeadlessUserAgent();
348
+ }
349
+ /**
350
+ * A headless Chromium advertises "HeadlessChrome/<v>" in its User-Agent, and
351
+ * DeepSeek's CDN (CloudFront/WAF) answers that UA with a plain "403 ERROR"
352
+ * page before the app is served — so sign-in worked with a visible window and
353
+ * silently failed headless. Read the UA the engine really reports and, when
354
+ * it carries the "Headless" marker, relaunch once with a sanitized
355
+ * `--user-agent` (the marker removed, the real version kept). A UA passed
356
+ * explicitly in the options/config is never touched.
357
+ */
358
+ async _fixHeadlessUserAgent() {
359
+ if (!this.headless)
360
+ return;
361
+ try {
362
+ const ua = await this.page.evaluate(() => navigator.userAgent);
363
+ const fixed = sanitizeHeadlessUA(ua);
364
+ if (fixed === ua)
365
+ return;
366
+ this.userAgent = fixed;
367
+ if (this.debug)
368
+ console.error('profile: headless UA → перезапуск с обычным Chrome UA');
369
+ await this.context.close().catch(() => { });
370
+ await this._launchOnce();
371
+ }
372
+ catch (e) {
373
+ if (this.debug)
374
+ console.error('profile: UA-фикс не удался:', e.message);
375
+ }
186
376
  }
187
377
  async _launchOnce() {
188
378
  const options = {
@@ -195,6 +385,8 @@ export class DeepSeekBrowser {
195
385
  };
196
386
  if (this.channel)
197
387
  options.channel = this.channel;
388
+ if (this.userAgent)
389
+ options.userAgent = this.userAgent;
198
390
  try {
199
391
  this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
200
392
  }
@@ -273,11 +465,47 @@ export class DeepSeekBrowser {
273
465
  await this.launch();
274
466
  }
275
467
  async waitForLogin() {
276
- const loggedIn = await this.isLoggedIn();
277
- if (loggedIn)
468
+ // 1) Already signed in (session cookie in the persistent profile)?
469
+ if (await this.isLoggedIn()) {
470
+ if (this.debug)
471
+ console.error('auth: сессия уже активна');
472
+ return;
473
+ }
474
+ // 2) Try to sign in automatically with the saved credentials. The
475
+ // password lives in the config (~/.zames/config.json) and is only used
476
+ // when the session is logged out — i.e. exactly the "re-login without
477
+ // asking" case. If the login form is absent (e.g. DeepSeek uses a
478
+ // captcha / another provider), this fails and we fall through.
479
+ if (this.auth.username && this.auth.password) {
480
+ console.log(theme.system(this._t('auth.auto_login')));
481
+ const ok = await this._autoLogin().catch((e) => {
482
+ console.log(theme.warn(this._t('auth.auto_login_failed', { v: e.message })));
483
+ return false;
484
+ });
485
+ if (ok) {
486
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
487
+ return;
488
+ }
489
+ }
490
+ // 3) Ask the operator for credentials in the terminal, try them, and
491
+ // only then fall back to the manual hint. Interactive only: in a
492
+ // pipe/redirect (or headless without credentials) we print a hint.
493
+ const asked = await this._promptAndLogin().catch((e) => {
494
+ if (this.debug)
495
+ console.error('auth: prompt error:', e.message);
496
+ return false;
497
+ });
498
+ if (asked)
278
499
  return;
279
- console.log('\n🔐 Залогиньтесь в DeepSeek в открытом браузере.');
280
- console.log(' После входа нажмите Enter в терминале...\n');
500
+ // 4) Last resort: manual sign-in in the browser window.
501
+ console.log('\n' + theme.warn(this._t('auth.need_login')));
502
+ if (this.headless) {
503
+ console.log(' ' + this._t('auth.manual_hint_headless'));
504
+ }
505
+ else {
506
+ console.log(' ' + this._t('auth.manual_hint'));
507
+ }
508
+ console.log('\n');
281
509
  const readline = await import('readline');
282
510
  const rl = readline.createInterface({
283
511
  input: process.stdin,
@@ -289,20 +517,286 @@ export class DeepSeekBrowser {
289
517
  resolve();
290
518
  });
291
519
  });
520
+ if (await this.isLoggedIn()) {
521
+ await this._persistSessionIfNeeded();
522
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
523
+ }
292
524
  }
293
- async isLoggedIn() {
294
- for (const sel of INPUT_SELECTORS) {
295
- try {
296
- await this.page.locator(sel).first().waitFor({
297
- state: 'visible',
298
- timeout: 3000,
299
- });
525
+ /**
526
+ * Try to sign in with this.auth. Returns true when the login form was
527
+ * filled and the session became active. Never throws for a missing form —
528
+ * the caller decides whether to fall back.
529
+ */
530
+ async _autoLogin() {
531
+ const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
532
+ if (!filled)
533
+ return false;
534
+ const ok = await this._waitLoggedIn(20_000);
535
+ if (ok)
536
+ await this._persistSessionIfNeeded();
537
+ return ok;
538
+ }
539
+ /**
540
+ * Ask the operator for login/password in the terminal (only in a TTY) and
541
+ * try them. An empty password falls back to the saved one. Returns true
542
+ * when the sign-in succeeded.
543
+ */
544
+ async _promptAndLogin() {
545
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
546
+ return false;
547
+ const hasSaved = !!(this.auth.username && this.auth.password);
548
+ // Prompt only when we have a login field to fill.
549
+ if (!(await this._loginFormVisible())) {
550
+ if (this.debug)
551
+ console.error('auth: форма входа не найдена');
552
+ return false;
553
+ }
554
+ console.log('\n' + theme.warn(this._t('auth.need_login')));
555
+ // Username via a normal readline question (echoed).
556
+ let user = this.auth.username;
557
+ if (!user) {
558
+ const readline = await import('readline');
559
+ const rl = readline.createInterface({
560
+ input: process.stdin,
561
+ output: process.stdout,
562
+ terminal: true,
563
+ });
564
+ user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
565
+ rl.close();
566
+ }
567
+ // Password via raw keystroke reading, echoed as `*` (readline would echo
568
+ // it in clear or swallow the prompt — see askPassword).
569
+ const passPromptKey = hasSaved
570
+ ? 'auth.prompt_password_saved'
571
+ : 'auth.prompt_password';
572
+ const pass = await askPassword(this._t(passPromptKey));
573
+ const password = pass || this.auth.password;
574
+ if (!user || !password)
575
+ return false;
576
+ console.log(theme.system(this._t('auth.auto_login')));
577
+ const filled = await this._fillLoginForm(user, password);
578
+ if (!filled) {
579
+ console.log(theme.warn(this._t('auth.form_not_found')));
580
+ return false;
581
+ }
582
+ const ok = await this._waitLoggedIn(20_000);
583
+ if (ok) {
584
+ // Remember the credentials so the next launch can re-login silently.
585
+ this.auth.username = user;
586
+ this.auth.password = password;
587
+ await this._persistSessionIfNeeded();
588
+ this.onAuthSave?.(user, password);
589
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
590
+ }
591
+ else {
592
+ // Detect a credentials error shown by DeepSeek, otherwise report a
593
+ // generic failure with the page hint so the operator can react.
594
+ const reason = await this._readLoginError();
595
+ if (reason) {
596
+ console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
597
+ }
598
+ else {
599
+ console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
600
+ }
601
+ }
602
+ return ok;
603
+ }
604
+ /**
605
+ * Read a visible login/credentials error message from the page, if any.
606
+ * Best-effort: returns '' when nothing recognizable is found.
607
+ */
608
+ async _readLoginError() {
609
+ try {
610
+ const txt = await this.page.evaluate(() => {
611
+ const sels = [
612
+ '[role="alert"]',
613
+ '[class*="error" i]',
614
+ '[class*="toast" i]',
615
+ '[class*="notification" i]',
616
+ ];
617
+ let out = '';
618
+ for (const s of sels) {
619
+ for (const el of Array.from(document.querySelectorAll(s))) {
620
+ const e = el;
621
+ const st = getComputedStyle(e);
622
+ if (st.display === 'none' || st.visibility === 'hidden')
623
+ continue;
624
+ const t = (e.innerText || '').trim();
625
+ if (t)
626
+ out += ' ' + t;
627
+ }
628
+ }
629
+ return out.replace(/\s+/g, ' ').trim();
630
+ });
631
+ return txt.slice(0, 200);
632
+ }
633
+ catch {
634
+ return '';
635
+ }
636
+ }
637
+ /** Is a login form (password field) present on the page? */
638
+ async _loginFormVisible() {
639
+ return await this.page
640
+ .locator(PASSWORD_SELECTORS.join(', '))
641
+ .first()
642
+ .isVisible({ timeout: 2000 })
643
+ .catch(() => false);
644
+ }
645
+ /**
646
+ * Fill the DeepSeek sign-in form with the given credentials and submit it.
647
+ * Returns false if the form (or the submit button) is not found.
648
+ */
649
+ async _fillLoginForm(username, password) {
650
+ const pwd = await this._findVisible(PASSWORD_SELECTORS, 4000);
651
+ if (!pwd)
652
+ return false;
653
+ // The email/phone field is the text input above the password field. Try
654
+ // the known selectors first, then fall back to the nearest text input.
655
+ let userInput = await this._findVisible(LOGIN_SELECTORS, 2000);
656
+ if (!userInput) {
657
+ // Find the index of the password input and use the closest preceding
658
+ // text/email/tel input as the login field.
659
+ const idx = await this.page.evaluate((pwdSel) => {
660
+ const p = document.querySelector(pwdSel);
661
+ if (!p)
662
+ return -1;
663
+ const inputs = Array.from(document.querySelectorAll('input'));
664
+ const pi = inputs.indexOf(p);
665
+ for (let i = pi - 1; i >= 0; i--) {
666
+ const t = (inputs[i].type || 'text').toLowerCase();
667
+ if (t === 'text' || t === 'email' || t === 'tel')
668
+ return i;
669
+ }
670
+ return -1;
671
+ }, PASSWORD_SELECTORS[0]);
672
+ if (idx >= 0)
673
+ userInput = this.page.locator('input').nth(idx);
674
+ }
675
+ if (!userInput)
676
+ return false;
677
+ await this._setInputText(userInput, username);
678
+ await this._setInputText(pwd, password);
679
+ await this.page.waitForTimeout(150);
680
+ // Submit. DeepSeek's login form is a React form; the button text is
681
+ // localized and classes change, so we try, in order:
682
+ // 1. known submit selectors;
683
+ // 2. a submit/primary button inside the same <form> as the password;
684
+ // 3. pressing Enter in the password field;
685
+ // 4. clicking the password field's closest button sibling.
686
+ // After each attempt we give the login a short window and stop as soon as
687
+ // it succeeds, so a stray click does not fire on an already-logged-in page.
688
+ const tryLogin = async () => {
689
+ if (await this._waitLoggedIn(3000))
300
690
  return true;
691
+ return false;
692
+ };
693
+ // Precise first attempt: DeepSeek's button is
694
+ // <div role="button" class="ds-button ds-button--primary ...">
695
+ // <span class="ds-button__content">Log in</span></div>
696
+ // Click exactly the one whose visible label is a login word, so we never
697
+ // hit "Log in with Google" or another primary button by mistake.
698
+ const precise = this.page
699
+ .locator('div[role="button"].ds-button--primary, button')
700
+ .filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
701
+ const pc = await precise.count().catch(() => 0);
702
+ if (pc > 0) {
703
+ try {
704
+ await precise.last().click({ timeout: 2000 });
705
+ if (await tryLogin())
706
+ return true;
707
+ }
708
+ catch { }
709
+ }
710
+ for (const sel of LOGIN_SUBMIT_SELECTORS) {
711
+ const btn = this.page.locator(sel).last();
712
+ try {
713
+ if ((await btn.count()) === 0)
714
+ continue;
715
+ if (!(await btn.isVisible().catch(() => false)))
716
+ continue;
717
+ await btn.click({ timeout: 2000 });
718
+ if (await tryLogin())
719
+ return true;
301
720
  }
302
721
  catch { }
303
722
  }
723
+ // Button inside the same form as the password field.
724
+ const formClicked = await this.page
725
+ .evaluate((pwdSel) => {
726
+ const p = document.querySelector(pwdSel);
727
+ if (!p)
728
+ return false;
729
+ const form = p.closest('form');
730
+ const scope = form || document;
731
+ const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
732
+ for (const b of btns) {
733
+ const txt = (b.textContent || '').trim().toLowerCase();
734
+ const aria = (b.getAttribute('aria-label') || '').toLowerCase();
735
+ if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
736
+ b.click();
737
+ return true;
738
+ }
739
+ }
740
+ // Fallback: the primary-looking button in the scope.
741
+ const primary = btns.find((b) => {
742
+ const cls = (b.className || '').toString();
743
+ return /primary|submit|ds-button--primary/i.test(cls);
744
+ });
745
+ if (primary) {
746
+ primary.click();
747
+ return true;
748
+ }
749
+ return false;
750
+ }, PASSWORD_SELECTORS[0])
751
+ .catch(() => false);
752
+ if (formClicked && (await tryLogin()))
753
+ return true;
754
+ // Enter in the password field.
755
+ await pwd.press('Enter').catch(() => { });
756
+ return true;
757
+ }
758
+ /** Poll isLoggedIn until it succeeds or the deadline passes. */
759
+ async _waitLoggedIn(timeoutMs) {
760
+ const deadline = Date.now() + timeoutMs;
761
+ while (Date.now() < deadline) {
762
+ if (await this.isLoggedIn().catch(() => false))
763
+ return true;
764
+ await this.page.waitForTimeout(500);
765
+ }
304
766
  return false;
305
767
  }
768
+ /**
769
+ * Persist the authenticated session. The persistent profile already keeps
770
+ * cookies on disk; `saveSession` (config) is kept as an explicit toggle for
771
+ * the operator. We additionally save a small marker so /doctor and the next
772
+ * launch can tell that a session exists.
773
+ */
774
+ async _persistSessionIfNeeded() {
775
+ if (!this.auth.saveSession)
776
+ return;
777
+ if (!(await this.isLoggedIn().catch(() => false)))
778
+ return;
779
+ try {
780
+ await fs.mkdir(path.join(os.homedir(), '.zames'), { recursive: true });
781
+ await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf-8');
782
+ }
783
+ catch { }
784
+ }
785
+ async isLoggedIn() {
786
+ // Single combined query with a short timeout: the old loop waited up to
787
+ // 3s PER selector (9s total), and _waitLoggedIn calls this repeatedly,
788
+ // which made login feel like it "hangs".
789
+ try {
790
+ await this.page
791
+ .locator(INPUT_SELECTORS.join(', '))
792
+ .first()
793
+ .waitFor({ state: 'visible', timeout: 2000 });
794
+ return true;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
306
800
  async newChat() {
307
801
  const newChatBtn = this.page
308
802
  .locator('button, a')
@@ -690,20 +1184,47 @@ export class DeepSeekBrowser {
690
1184
  const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
691
1185
  if (gap <= 0)
692
1186
  return;
693
- console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
694
- await this._sleepInterruptible(gap);
1187
+ // Report the pause to the UI as an ANIMATED status (with the remaining
1188
+ // seconds) instead of the old static console line — the dots used to be
1189
+ // frozen here, which looked like the spinner had hung. The seconds are
1190
+ // refreshed once per second, so the status visibly counts down.
1191
+ const report = (leftMs) => {
1192
+ const secs = Math.max(0, Math.ceil(leftMs / 1000));
1193
+ if (this.onSendPause) {
1194
+ try {
1195
+ this.onSendPause(secs);
1196
+ }
1197
+ catch { }
1198
+ }
1199
+ else {
1200
+ console.error(theme.warn(`⏳ send pause ${secs}s`));
1201
+ }
1202
+ };
1203
+ report(gap);
1204
+ await this._sleepInterruptible(gap, report);
695
1205
  }
696
1206
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
697
1207
  // Returns true if the sleep was cut short by _abort.
698
- async _sleepInterruptible(ms) {
1208
+ //
1209
+ // onTick (optional) is called about once per second with the remaining time,
1210
+ // so the UI can show a live countdown during the pause.
1211
+ async _sleepInterruptible(ms, onTick) {
699
1212
  const step = 100;
700
1213
  let left = ms;
1214
+ let sinceTick = 0;
701
1215
  while (left > 0) {
702
1216
  if (this._abort)
703
1217
  return true;
704
1218
  const chunk = Math.min(step, left);
705
1219
  await this.page.waitForTimeout(chunk);
706
1220
  left -= chunk;
1221
+ if (onTick) {
1222
+ sinceTick += chunk;
1223
+ if (sinceTick >= 1000) {
1224
+ sinceTick = 0;
1225
+ onTick(left);
1226
+ }
1227
+ }
707
1228
  }
708
1229
  return this._abort;
709
1230
  }
package/dist/commands.js CHANGED
@@ -153,6 +153,7 @@ export function renderDoctor(d) {
153
153
  row(d.gitOk, 'git', d.gitOk ? 'repo (' + (d.gitBranch || 'detached') + ')' : 'not a repository');
154
154
  row(d.configOk, 'config', d.configOk ? 'loaded' : 'error: ' + (d.configError || 'unknown'));
155
155
  row(true, 'browser', d.browserChannel ? d.browserChannel : 'bundled chromium');
156
+ row(!!d.authSaved, 'auth', d.authSaved ? 'session saved (auto re-login ready)' : 'no saved session');
156
157
  row(!!d.clipboardTool, 'clipboard', d.clipboardTool || 'no tool found (image paste disabled)');
157
158
  row(true, 'mcp', d.mcpServers + ' server(s), ' + d.mcpTools + ' tool(s)');
158
159
  row(d.transcriptOk, 'transcript', d.transcriptOk ? 'on' : 'off');
@@ -19,6 +19,10 @@ function displayValue(field, value, t) {
19
19
  return t('cfg.menu.default');
20
20
  if (typeof value === 'boolean')
21
21
  return value ? t('common.on') : t('common.off');
22
+ // Never print a password in clear text: show a fixed mask when set.
23
+ if (/password/i.test(field.path) && String(value).length > 0) {
24
+ return '********';
25
+ }
22
26
  return String(value);
23
27
  }
24
28
  function groupLabel(field, t) {
package/dist/config.js CHANGED
@@ -7,7 +7,9 @@ const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
7
7
  const PROJECT_CONFIG = path.join(process.cwd(), '.zamesrc.json');
8
8
  export const DEFAULTS = {
9
9
  maxIterations: 0,
10
- headless: false,
10
+ // Headless by default: the browser runs without a visible window. Set
11
+ // headless: false (or pass --headed) to watch/debug the DeepSeek page.
12
+ headless: true,
11
13
  debug: false,
12
14
  browserChannel: null,
13
15
  confirmation: {
@@ -43,6 +45,11 @@ export const DEFAULTS = {
43
45
  maxRateLimitRetries: 6,
44
46
  deepThinking: false,
45
47
  webSearch: true,
48
+ auth: {
49
+ username: '',
50
+ password: '',
51
+ saveSession: true,
52
+ },
46
53
  },
47
54
  ui: {
48
55
  locale: DEFAULT_LOCALE,
@@ -81,6 +88,9 @@ export const CONFIG_SCHEMA = [
81
88
  { path: 'ui.locale', type: 'enum', values: ['ru', 'en'], labelKey: 'cfg.f.ui_locale', groupKey: 'cfg.group.ui' },
82
89
  { path: 'maxIterations', type: 'number', min: 0, max: 100000, labelKey: 'cfg.f.maxIterations', groupKey: 'cfg.group.agent' },
83
90
  { path: 'headless', type: 'boolean', labelKey: 'cfg.f.headless', groupKey: 'cfg.group.agent' },
91
+ { path: 'browser.auth.username', type: 'string', labelKey: 'cfg.f.browser_authUsername', groupKey: 'cfg.group.browser' },
92
+ { path: 'browser.auth.password', type: 'string', labelKey: 'cfg.f.browser_authPassword', groupKey: 'cfg.group.browser' },
93
+ { path: 'browser.auth.saveSession', type: 'boolean', labelKey: 'cfg.f.browser_authSaveSession', groupKey: 'cfg.group.browser' },
84
94
  { path: 'debug', type: 'boolean', labelKey: 'cfg.f.debug', groupKey: 'cfg.group.agent' },
85
95
  { path: 'hotReload', type: 'boolean', labelKey: 'cfg.f.hotReload', groupKey: 'cfg.group.agent' },
86
96
  { path: 'confirmation.write', type: 'boolean', labelKey: 'cfg.f.confirmation_write', groupKey: 'cfg.group.confirmation' },
package/dist/i18n.js CHANGED
@@ -46,7 +46,8 @@ const CATALOG = {
46
46
  'help.opt.new_chat': { ru: 'начать новый чат (по умолчанию)', en: 'start a new chat (default)' },
47
47
  'help.opt.resend_prompt': { ru: 'дослать system-prompt в существующий чат', en: 'resend system-prompt into an existing chat' },
48
48
  'help.opt.max_iter': { ru: 'лимит итераций, 0 = без лимита ({n})', en: 'iteration limit, 0 = unlimited ({n})' },
49
- 'help.opt.headless': { ru: 'браузер без UI', en: 'headless browser' },
49
+ 'help.opt.headless': { ru: 'браузер без UI (по умолчанию)', en: 'headless browser (default)' },
50
+ 'help.opt.headed': { ru: 'показать окно браузера (отладка/вход)', en: 'show the browser window (debug/sign-in)' },
50
51
  'help.opt.debug': { ru: 'подробный лог', en: 'verbose log' },
51
52
  'help.opt.calibrate': { ru: 'режим калибровки селекторов', en: 'selector calibration mode' },
52
53
  'help.opt.dev': { ru: 'режим разработки: авто-перечитывание модулей', en: 'dev mode: auto-reload modules' },
@@ -132,6 +133,9 @@ const CATALOG = {
132
133
  en: 'Mixing the slurry…|Whipping it to a smooth paste…|Stirring logic into it…|Shaking it into a cocktail…|Whisking it around…|Blending it with caffeine…|Kneading dough out of it…|Whipping it into foam…|Tossing it with a spatula…|Kneading clay out of it…|Homogenizing it into concrete…|Shaking it until clear…|Blending it into a smoothie…|Mixing cement out of it…|Whipping porridge out of it…|Stirring it with hope…|Swirling the sediment…|Running the blender over it…|Mixing it beyond recognition…|Mixing mortar out of it…|Whipping a cocktail of it…|Stirring it with the deadline spatula…|Whipping foam out of it…|Homogenizing it evenly…|Tumbling it in a cement mixer…|Beating it into a uniform mass…|Stirring it until clear…|Mixing it from whatever is around…',
133
134
  },
134
135
  'spinner.hint': { ru: 'Esc — стоп', en: 'Esc — stop' },
136
+ 'spinner.pause': { ru: '⏳ пауза {n}с перед отправкой', en: '⏳ pause {n}s before send' },
137
+ 'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
138
+ 'editor.answer': { ru: '● Ответ', en: '● Answer' },
135
139
  // ---------- status ----------
136
140
  'status.workdir': { ru: 'Рабочая директория: {v}', en: 'Working directory: {v}' },
137
141
  'status.review_mode': { ru: 'Режим ревью: {v}', en: 'Review mode: {v}' },
@@ -295,6 +299,37 @@ const CATALOG = {
295
299
  'cfg.f.browser_maxRateLimitRetries': { ru: 'Число повторов при лимите', en: 'Rate-limit retry count' },
296
300
  'cfg.f.browser_deepThinking': { ru: 'Глубокое мышление (долго; размышления не выводятся)', en: 'Deep thinking (slow; reasoning is hidden)' },
297
301
  'cfg.f.browser_webSearch': { ru: 'Умный поиск в интернете', en: 'Smart web search' },
302
+ 'cfg.f.browser_authUsername': { ru: 'Логин DeepSeek для авто-входа', en: 'DeepSeek login for auto sign-in' },
303
+ 'cfg.f.browser_authPassword': { ru: 'Пароль DeepSeek для авто-входа', en: 'DeepSeek password for auto sign-in' },
304
+ 'cfg.f.browser_authSaveSession': { ru: 'Сохранять сессию входа в профиле', en: 'Persist the login session in the profile' },
305
+ // ---------- login / auth ----------
306
+ 'auth.need_login': { ru: '🔐 Требуется вход в DeepSeek.', en: '🔐 DeepSeek sign-in required.' },
307
+ 'auth.prompt_login': { ru: 'Логин DeepSeek (телефон/email): ', en: 'DeepSeek login (phone/email): ' },
308
+ 'auth.prompt_password': { ru: 'Пароль DeepSeek: ', en: 'DeepSeek password: ' },
309
+ 'auth.prompt_password_saved': {
310
+ ru: 'Пароль DeepSeek (Enter — использовать сохранённый): ',
311
+ en: 'DeepSeek password (Enter — use the saved one): ',
312
+ },
313
+ 'auth.auto_login': { ru: 'Авто-вход в DeepSeek...', en: 'Signing in to DeepSeek...' },
314
+ 'auth.auto_login_ok': { ru: 'Вход выполнен.', en: 'Signed in.' },
315
+ 'auth.auto_login_failed': { ru: 'Авто-вход не удался: {v}', en: 'Auto sign-in failed: {v}' },
316
+ 'auth.login_rejected': { ru: 'DeepSeek отклонил вход: {v}', en: 'DeepSeek rejected the sign-in: {v}' },
317
+ 'auth.form_not_found': {
318
+ ru: 'Форма входа не распознана. Запустите с --headed и войдите вручную.',
319
+ en: 'Login form not recognized. Run with --headed and sign in manually.',
320
+ },
321
+ 'auth.no_reason': {
322
+ ru: 'причина не определена (возможно, неверный логин/пароль или капча)',
323
+ en: 'no reason detected (possibly wrong login/password or a captcha)',
324
+ },
325
+ 'auth.manual_hint': { ru: 'Войдите в DeepSeek в открытом браузере, затем нажмите Enter.', en: 'Sign in to DeepSeek in the open browser, then press Enter.' },
326
+ 'auth.manual_hint_headless': {
327
+ ru: 'Войдите в DeepSeek: откройте https://chat.deepseek.com/ в своём браузере. Если включён headless — задайте логин/пароль через /config или запустите с --headed.',
328
+ en: 'Sign in to DeepSeek: open https://chat.deepseek.com/ in your browser. If headless is on — set the login/password via /config or run with --headed.',
329
+ },
330
+ 'auth.session_saved': { ru: 'Сессия DeepSeek сохранена.', en: 'DeepSeek session saved.' },
331
+ 'auth.session_restored': { ru: 'Сессия DeepSeek восстановлена.', en: 'DeepSeek session restored.' },
332
+ 'auth.enter_to_continue': { ru: 'Нажмите Enter, чтобы продолжить...', en: 'Press Enter to continue...' },
298
333
  'cfg.menu.title': { ru: '⚙ Настройки — выбери параметр', en: '⚙ Settings — choose a parameter' },
299
334
  'cfg.menu.hint': { ru: '↑/↓ — выбор, Enter — изменить, q/Esc — выйти', en: '↑/↓ select, Enter edit, q/Esc quit' },
300
335
  'cfg.menu.edit_hint': { ru: 'Введи значение и Enter (пусто — отмена)', en: 'Enter a value and press Enter (empty — cancel)' },
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
+ import { existsSync } from 'fs';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { theme } from './theme.js';
6
7
  import { DeepSeekBrowser } from './browser.js';
@@ -19,6 +20,16 @@ import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCo
19
20
  import { closeWeb } from './web.js';
20
21
  import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
21
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ // True when a DeepSeek session/credentials were stored earlier (auth.json).
24
+ // Used by /doctor to report that auto re-login is ready.
25
+ function authMarkerExists() {
26
+ try {
27
+ return existsSync(path.join(ZAMES_HOME, 'auth.json'));
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
22
33
  // ---------- CLI parsing ----------
23
34
  const args = process.argv.slice(2);
24
35
  function getArg(flag, fallback = null) {
@@ -50,7 +61,11 @@ let currentLocale = isLocale(config.ui?.locale)
50
61
  // Translation: reads currentLocale at call time, so a language change takes
51
62
  // effect immediately, without a restart (for lines printed afterwards).
52
63
  const t = (key, params) => translate(currentLocale)(key, params);
53
- const headless = hasFlag('--headless') || config.headless;
64
+ // Headless is the default. --headed forces a visible window (useful for the
65
+ // first sign-in or debugging the DOM). --headless keeps it explicit.
66
+ const headless = hasFlag('--headed')
67
+ ? false
68
+ : hasFlag('--headless') || config.headless;
54
69
  const debug = hasFlag('--debug') || config.debug;
55
70
  const calibrate = hasFlag('--calibrate');
56
71
  const maxIterArg = getArg('--max-iter', null);
@@ -189,6 +204,7 @@ ${theme.bold(t('help.options'))}
189
204
  --resend-prompt ${t('help.opt.resend_prompt')}
190
205
  --max-iter <n> ${t('help.opt.max_iter', { n: config.maxIterations })}
191
206
  --headless ${t('help.opt.headless')}
207
+ --headed ${t('help.opt.headed')}
192
208
  --debug ${t('help.opt.debug')}
193
209
  --calibrate ${t('help.opt.calibrate')}
194
210
  --dev ${t('help.opt.dev')}
@@ -828,6 +844,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
828
844
  attachments: next.attachments || [],
829
845
  transcript,
830
846
  onThinking: () => ui.thinking(),
847
+ onSendPause: (seconds) => ui.sendPause(seconds),
831
848
  onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
832
849
  onToolResult: (result) => ui.toolResult(result),
833
850
  onAssistantMessage: (msg) => ui.assistant(msg),
@@ -881,9 +898,10 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
881
898
  finally {
882
899
  stopWatching();
883
900
  ui.stop();
884
- // Detach the send hook so a later browser.ask() outside this task cannot
901
+ // Detach the send hooks so a later browser.ask() outside this task cannot
885
902
  // start a stale spinner.
886
903
  browser.onSendStart = null;
904
+ browser.onSendPause = null;
887
905
  }
888
906
  }
889
907
  // ---------- main ----------
@@ -944,6 +962,18 @@ async function main() {
944
962
  debug,
945
963
  channel: config.browserChannel,
946
964
  ...config.browser,
965
+ locale: currentLocale,
966
+ // Persist credentials the operator typed in the terminal so the next
967
+ // launch can re-login silently after a logout.
968
+ onAuthSave: (username, password) => {
969
+ config.browser.auth.username = username;
970
+ config.browser.auth.password = password;
971
+ try {
972
+ writeConfigValue('home', 'browser.auth.username', username);
973
+ writeConfigValue('home', 'browser.auth.password', password);
974
+ }
975
+ catch { }
976
+ },
947
977
  });
948
978
  const bootSpinner = mod.createSpinner(currentLocale);
949
979
  bootSpinner.thinking();
@@ -1096,6 +1126,7 @@ async function main() {
1096
1126
  const ed = new LineEditor({
1097
1127
  prompt: buildPrompt(),
1098
1128
  commands: buildSlashCommands(),
1129
+ locale: currentLocale,
1099
1130
  });
1100
1131
  editor = ed;
1101
1132
  ed.setTmpDir(TMP_DIR);
@@ -1259,6 +1290,7 @@ async function main() {
1259
1290
  currentLocale = value;
1260
1291
  config.ui.locale = value;
1261
1292
  if (editor) {
1293
+ editor.setLocale(value);
1262
1294
  editor.setCommands(buildSlashCommands());
1263
1295
  editor.setPrompt(buildPrompt());
1264
1296
  }
@@ -1304,7 +1336,9 @@ async function main() {
1304
1336
  ? cur
1305
1337
  ? t('common.on')
1306
1338
  : t('common.off')
1307
- : JSON.stringify(cur);
1339
+ : /password/i.test(f.path) && String(cur).length > 0
1340
+ ? '********'
1341
+ : JSON.stringify(cur);
1308
1342
  const extra = f.values ? ' [' + f.values.join('|') + ']' : '';
1309
1343
  console.log(' ' +
1310
1344
  theme.user(f.path) +
@@ -1395,7 +1429,11 @@ async function main() {
1395
1429
  const cur = getByPath(config, key);
1396
1430
  console.log(theme.system(t('cfg.value', {
1397
1431
  v: key,
1398
- value: cur === undefined ? t('cfg.menu.default') : JSON.stringify(cur),
1432
+ value: cur === undefined
1433
+ ? t('cfg.menu.default')
1434
+ : /password/i.test(key) && String(cur).length > 0
1435
+ ? '********'
1436
+ : JSON.stringify(cur),
1399
1437
  })));
1400
1438
  return;
1401
1439
  }
@@ -2101,6 +2139,7 @@ async function main() {
2101
2139
  mcpServers: mcpStatus.servers.length,
2102
2140
  mcpTools: mcpStatus.toolCount,
2103
2141
  transcriptOk: !!transcript.file,
2142
+ authSaved: authMarkerExists(),
2104
2143
  })));
2105
2144
  continue;
2106
2145
  }
package/dist/input.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { theme } from './theme.js';
2
2
  import { renderMarkdown } from './markdown.js';
3
3
  import { randomThinkingPhrase, stripEllipsis } from './spinner.js';
4
+ import { translate } from './i18n.js';
4
5
  import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
5
6
  // A permanent input line at the bottom of the terminal + a status/output area above it.
6
7
  //
@@ -70,7 +71,6 @@ export function expandPastes(pastes, text) {
70
71
  // We align the width to the maximum (3) so the hint doesn't shift.
71
72
  const DOTS = ['', '.', '..', '...'];
72
73
  const DOTS_PAD = ' ';
73
- const HINT = theme.dim(' · Esc — стоп');
74
74
  function safeJson(v) {
75
75
  try {
76
76
  return JSON.stringify(v);
@@ -184,6 +184,9 @@ export class LineEditor {
184
184
  onCtrlC;
185
185
  _inPaste;
186
186
  _dotTimer;
187
+ // True while the dot-animation timer is running. Lets sendPause() update
188
+ // only the base text (remaining seconds) without restarting the timer.
189
+ _animating;
187
190
  _dotPhase;
188
191
  _thinkBase;
189
192
  _wasRaw;
@@ -212,7 +215,11 @@ export class LineEditor {
212
215
  // user could type and send a message mid-operation, and it would be queued
213
216
  // and sent right after the operation, breaking the restored session.
214
217
  locked;
215
- constructor({ prompt = '> ', commands = [] } = {}) {
218
+ // Interface language for the editor's own labels (hint, answer marker,
219
+ // pause status). Everything the OPERATOR sees must be localized.
220
+ locale;
221
+ constructor({ prompt = '> ', commands = [], locale = 'ru' } = {}) {
222
+ this.locale = locale;
216
223
  this.promptStr = prompt;
217
224
  this.buf = '';
218
225
  this.cursor = 0;
@@ -226,6 +233,7 @@ export class LineEditor {
226
233
  this.onCtrlC = null;
227
234
  this._inPaste = false;
228
235
  this._dotTimer = null;
236
+ this._animating = false;
229
237
  this._dotPhase = 0;
230
238
  this._thinkBase = '';
231
239
  this._wasRaw = false;
@@ -362,6 +370,11 @@ export class LineEditor {
362
370
  this.promptStr = str;
363
371
  this._render();
364
372
  }
373
+ // Update the interface language (labels: hint, answer marker, pause).
374
+ setLocale(locale) {
375
+ this.locale = locale;
376
+ this._render();
377
+ }
365
378
  // Update slash-command descriptions (interface language change).
366
379
  setCommands(commands) {
367
380
  this.slashCommands = commands;
@@ -412,7 +425,7 @@ export class LineEditor {
412
425
  out += NL + lines.join(NL);
413
426
  const hidden = sugg.length - shown.length;
414
427
  if (hidden > 0)
415
- out += NL + theme.dim(' …ещё ' + hidden);
428
+ out += NL + theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
416
429
  }
417
430
  process.stdout.write(out);
418
431
  const lastRow = lay.rows.length - 1;
@@ -449,36 +462,65 @@ export class LineEditor {
449
462
  clearInterval(this._dotTimer);
450
463
  this._dotTimer = null;
451
464
  }
465
+ this._animating = false;
452
466
  }
453
467
  _dots(n) {
454
468
  // The dots are the same color as the base and of fixed width — otherwise
455
469
  // the hint on the right "jumps" when the animation phase changes.
456
470
  return theme.brown(DOTS[n] + DOTS_PAD.slice(DOTS[n].length));
457
471
  }
472
+ // Status-line hint ("Esc — stop"), localized.
473
+ _hint() {
474
+ return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
475
+ }
458
476
  _startThinking() {
459
477
  if (this.pendingText) {
460
- this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT);
478
+ this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
461
479
  return;
462
480
  }
463
- this._thinkBase = theme.brown(stripEllipsis(randomThinkingPhrase()));
481
+ this._startAnimated(randomThinkingPhrase());
482
+ }
483
+ // Animated status: a brown base text plus a growing "running" dot sequence.
484
+ // Shared by the thinking spinner and the send-pause indicator, so the pause
485
+ // is animated too (previously the pause was a static console line and the
486
+ // dots stayed frozen).
487
+ _startAnimated(baseText) {
488
+ this._thinkBase = theme.brown(stripEllipsis(baseText));
464
489
  this._dotPhase = 0;
465
- this.setStatus(this._thinkBase + this._dots(0) + HINT);
490
+ this.setStatus(this._thinkBase + this._dots(0) + this._hint());
466
491
  this._stopDots();
467
492
  this._dotTimer = setInterval(() => {
468
493
  this._dotPhase = (this._dotPhase + 1) % DOTS.length;
469
- this.setStatus(this._thinkBase + this._dots(this._dotPhase) + HINT);
494
+ this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
470
495
  }, 400);
496
+ this._animating = true;
471
497
  if (this._dotTimer.unref)
472
498
  this._dotTimer.unref();
473
499
  }
474
500
  thinking() {
475
501
  this._startThinking();
476
502
  }
503
+ // The agent is waiting out the send-interval pause before a real send.
504
+ // Show it as an ANIMATED status with the remaining seconds. The browser
505
+ // calls this repeatedly (once per second), so we update only the base text
506
+ // and keep the running dot timer — otherwise the dots would restart from
507
+ // zero on every update and look frozen on a single dot.
508
+ sendPause(seconds) {
509
+ if (this.pendingText)
510
+ return;
511
+ const label = translate(this.locale)('spinner.pause', { n: seconds });
512
+ if (this._animating && this._dotTimer) {
513
+ this._thinkBase = theme.brown(stripEllipsis(label));
514
+ this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
515
+ return;
516
+ }
517
+ this._startAnimated(label);
518
+ }
477
519
  setPending(text) {
478
520
  this.pendingText = text && String(text).length ? String(text) : null;
479
521
  if (this.pendingText) {
480
522
  this._stopDots();
481
- this.setStatus(theme.prompt('✎ ') + this.pendingText + HINT);
523
+ this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
482
524
  }
483
525
  else {
484
526
  this._startThinking();
@@ -500,7 +542,7 @@ export class LineEditor {
500
542
  assistant(msg) {
501
543
  this.stop();
502
544
  const rendered = renderMarkdown(msg);
503
- this.printAbove(NL + theme.assistant('● Ответ') + NL + rendered + NL + theme.dim('─'.repeat(60)));
545
+ this.printAbove(NL + theme.assistant(translate(this.locale)('editor.answer')) + NL + rendered + NL + theme.dim('─'.repeat(60)));
504
546
  }
505
547
  // Warning to the operator (e.g. the agent stopped suspiciously).
506
548
  // Printed above the input line without overwriting it.
package/dist/spinner.js CHANGED
@@ -20,6 +20,12 @@ export function createSpinner(locale = 'ru') {
20
20
  let dotTimer = null;
21
21
  let dotPhase = 0;
22
22
  let pending = null;
23
+ // Current animated base text and its dot renderer. Kept in variables (not
24
+ // captured in the interval closure) so sendPause() can update the text
25
+ // without restarting the dot animation — otherwise the dots would reset to
26
+ // zero every second and look frozen on a single dot.
27
+ let animBase = '';
28
+ let animating = false;
23
29
  // Dot animation: start from an empty string (0 dots), then grow.
24
30
  // We align the width to the maximum (3) so the hint doesn't shift.
25
31
  const DOTS = ['', '.', '..', '...'];
@@ -35,19 +41,23 @@ export function createSpinner(locale = 'ru') {
35
41
  clearInterval(dotTimer);
36
42
  dotTimer = null;
37
43
  }
44
+ animating = false;
38
45
  if (spinner)
39
46
  spinner.stop();
40
47
  };
41
48
  // Hint in the status line: while the agent works the terminal is live,
42
49
  // so you can type the next message. Without it this is not obvious.
43
50
  const HINT = theme.dim(' · ' + translate(locale)('spinner.hint'));
44
- // Start the animated status: brown text + "running" dots.
45
- const startThinking = () => {
46
- const base = theme.brown(stripEllipsis(randomThinkingPhrase(locale)));
51
+ // Run the animated status line: a brown base text plus a growing/shrinking
52
+ // "running" dot sequence. Shared by the thinking spinner and the send-pause
53
+ // indicator, so the pause is animated too (before, it was a static line and
54
+ // the dots did not move — the operator saw a frozen spinner).
55
+ const startAnimated = (baseText) => {
56
+ animBase = theme.brown(stripEllipsis(baseText));
47
57
  // The dots are the same color as the base and of fixed width — otherwise
48
58
  // the hint on the right "jumps" when the animation phase changes.
49
59
  const dots = (n) => theme.brown(DOTS[n] + DOTS_PAD.slice(DOTS[n].length));
50
- start(base + dots(0) + HINT);
60
+ start(animBase + dots(0) + HINT);
51
61
  dotPhase = 0;
52
62
  if (dotTimer)
53
63
  clearInterval(dotTimer);
@@ -55,11 +65,16 @@ export function createSpinner(locale = 'ru') {
55
65
  if (!spinner)
56
66
  return;
57
67
  dotPhase = (dotPhase + 1) % DOTS.length;
58
- spinner.text = base + dots(dotPhase) + HINT;
68
+ spinner.text = animBase + dots(dotPhase) + HINT;
59
69
  }, 400);
70
+ animating = true;
60
71
  if (dotTimer.unref)
61
72
  dotTimer.unref();
62
73
  };
74
+ // Start the animated status: brown text + "running" dots.
75
+ const startThinking = () => {
76
+ startAnimated(randomThinkingPhrase(locale));
77
+ };
63
78
  // Show the typed but not yet sent text instead of the spinner.
64
79
  // Lets you type a message right while the agent is working.
65
80
  const showPending = () => {
@@ -67,6 +82,7 @@ export function createSpinner(locale = 'ru') {
67
82
  clearInterval(dotTimer);
68
83
  dotTimer = null;
69
84
  }
85
+ animating = false;
70
86
  if (!spinner)
71
87
  spinner = ora('');
72
88
  spinner.start();
@@ -80,6 +96,23 @@ export function createSpinner(locale = 'ru') {
80
96
  }
81
97
  startThinking();
82
98
  },
99
+ // The agent is waiting out the send-interval pause before a real send.
100
+ // Show it as an ANIMATED status line with the remaining seconds, so the
101
+ // spinner keeps moving during the pause (previously a static console line
102
+ // was printed and the dots stayed frozen).
103
+ sendPause: (seconds) => {
104
+ if (pending)
105
+ return;
106
+ const label = translate(locale)('spinner.pause', { n: seconds });
107
+ // Update only the base text while the dot animation is already running,
108
+ // so the countdown refreshes without resetting the dots.
109
+ if (animating && dotTimer && spinner) {
110
+ animBase = theme.brown(stripEllipsis(label));
111
+ spinner.text = animBase + theme.brown(DOTS[dotPhase] + DOTS_PAD.slice(DOTS[dotPhase].length)) + HINT;
112
+ return;
113
+ }
114
+ startAnimated(label);
115
+ },
83
116
  // The text the user types while the agent is working.
84
117
  // Empty string / null — restore the regular spinner.
85
118
  setPending: (text) => {
@@ -106,7 +139,7 @@ export function createSpinner(locale = 'ru') {
106
139
  const rendered = renderMarkdown(msg);
107
140
  // Model answer marker: helps visually separate it from the user's input
108
141
  // (which is highlighted by the prompt with a golden arrow).
109
- console.log(NL + theme.assistant('● Ответ') + NL);
142
+ console.log(NL + theme.assistant(translate(locale)('editor.answer')) + NL);
110
143
  console.log(rendered);
111
144
  console.log(theme.dim('─'.repeat(60)) + NL);
112
145
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {