zames_pro 2.72.8 → 2.72.9

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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.72.9] - 2026-10-10
11
+
12
+ ### Fixed
13
+
14
+ - **release:** do not duplicate the Unreleased body into the version section
15
+ - **context:** raise the context budget and split agents/memory pools
16
+ - **browser:** handle the DeepSeek email MFA dialog on login
17
+ - **subagent:** fail safe when the parent chat id is unknown
18
+ - **tools:** honor a skill's allowed-tools frontmatter
19
+ - **context:** keep newest MEMORY.md notes and write facts to the project memory
20
+
10
21
  ## [2.72.8] - 2026-10-09
11
22
 
12
23
  ### Fixed
@@ -57,6 +57,27 @@ export function readLoginErrorInDom() {
57
57
  }
58
58
  return out.replace(/\s+/g, ' ').trim();
59
59
  }
60
+ /**
61
+ * Read the account email shown in the MFA verification dialog, so the operator
62
+ * knows WHERE the one-time code was sent (the code arrives by email; without
63
+ * this the operator cannot tell which inbox to check). Best-effort: '' when the
64
+ * dialog or the email line is absent.
65
+ */
66
+ export function readMfaAccountInDom() {
67
+ const sels = [
68
+ '.ds-mfa-verification-account',
69
+ '.ds-mfa-verification-modal [title*="@"]',
70
+ ];
71
+ for (const s of sels) {
72
+ const el = document.querySelector(s);
73
+ if (!el)
74
+ continue;
75
+ const t = (el.getAttribute('title') || el.innerText || '').trim();
76
+ if (t)
77
+ return t;
78
+ }
79
+ return '';
80
+ }
60
81
  /**
61
82
  * The answer as RENDERED on the page, ALWAYS from the DOM (never the network
62
83
  * capture). Reasoning blocks (think class) are skipped and must never be
package/dist/browser.js CHANGED
@@ -2,9 +2,9 @@ import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
4
  import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
5
- import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
5
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, MFA_CODE_SELECTORS, MFA_SUBMIT_SELECTORS, MFA_SEND_CODE_SELECTORS, MFA_SEND_CODE_RE, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
6
6
  import { probeUiHealth, } from './ui-health.js';
7
- import { readPageToastsInDom, readLoginErrorInDom, readLastAnswerInDom, chatSignalInDom, continueVisibleCheapInDom, stopButtonVisibleInDom, clickStopInDom, continueVisibleInDom, clickContinueInDom, readUserAgentInDom, findLoginInputIndexInDom, submitLoginFormInDom, setBusyTitleInDom, pasteTextIntoElement, readElementText, elementTagNameInDom, isElementEnabledInDom, readInputTrimmedTextInDom, dumpDomCountsInDom, } from './browser-dom.js';
7
+ import { readPageToastsInDom, readLoginErrorInDom, readMfaAccountInDom, readLastAnswerInDom, chatSignalInDom, continueVisibleCheapInDom, stopButtonVisibleInDom, clickStopInDom, continueVisibleInDom, clickContinueInDom, readUserAgentInDom, findLoginInputIndexInDom, submitLoginFormInDom, setBusyTitleInDom, pasteTextIntoElement, readElementText, elementTagNameInDom, isElementEnabledInDom, readInputTrimmedTextInDom, dumpDomCountsInDom, } from './browser-dom.js';
8
8
  import path from 'path';
9
9
  import os from 'os';
10
10
  import fs from 'fs/promises';
@@ -554,10 +554,17 @@ export class DeepSeekBrowser {
554
554
  const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
555
555
  if (!filled)
556
556
  return false;
557
- const ok = await this._waitLoggedIn(20_000);
558
- if (ok)
557
+ const ok = await this._waitLoggedInOrMfa(20_000);
558
+ if (ok) {
559
559
  await this._persistSessionIfNeeded();
560
- return ok;
560
+ return true;
561
+ }
562
+ // Password accepted but DeepSeek now wants an email one-time code: ask the
563
+ // operator for it instead of failing the whole auto-login.
564
+ if (process.stdin.isTTY && (await this._mfaVisible())) {
565
+ return await this._handleMfaIfPresent();
566
+ }
567
+ return false;
561
568
  }
562
569
  /**
563
570
  * Ask the operator for login/password in the terminal (only in a TTY) and
@@ -602,7 +609,7 @@ export class DeepSeekBrowser {
602
609
  console.log(theme.warn(this._t('auth.form_not_found')));
603
610
  return false;
604
611
  }
605
- const ok = await this._waitLoggedIn(20_000);
612
+ const ok = await this._waitLoggedInOrMfa(20_000);
606
613
  if (ok) {
607
614
  // Remember the credentials so the next launch can re-login silently.
608
615
  this.auth.username = user;
@@ -610,10 +617,23 @@ export class DeepSeekBrowser {
610
617
  await this._persistSessionIfNeeded();
611
618
  this.onAuthSave?.(user, password);
612
619
  console.log(theme.assistant(this._t('auth.auto_login_ok')));
620
+ return true;
613
621
  }
614
- else {
615
- // Detect a credentials error shown by DeepSeek, otherwise report a
616
- // generic failure with the page hint so the operator can react.
622
+ // Password accepted, but DeepSeek wants an email one-time code. Ask the
623
+ // operator for it and submit the dialog.
624
+ if (await this._mfaVisible()) {
625
+ // The credentials are valid — remember them even before MFA completes,
626
+ // so the next launch can skip straight to the code dialog.
627
+ this.auth.username = user;
628
+ this.auth.password = password;
629
+ this.onAuthSave?.(user, password);
630
+ const mfaOk = await this._handleMfaIfPresent();
631
+ if (mfaOk)
632
+ return true;
633
+ }
634
+ // Detect a credentials error shown by DeepSeek, otherwise report a
635
+ // generic failure with the page hint so the operator can react.
636
+ {
617
637
  const reason = await this._readLoginError();
618
638
  if (reason) {
619
639
  console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
@@ -622,7 +642,7 @@ export class DeepSeekBrowser {
622
642
  console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
623
643
  }
624
644
  }
625
- return ok;
645
+ return false;
626
646
  }
627
647
  /**
628
648
  * Read a visible login/credentials error message from the page, if any.
@@ -677,7 +697,11 @@ export class DeepSeekBrowser {
677
697
  // After each attempt we give the login a short window and stop as soon as
678
698
  // it succeeds, so a stray click does not fire on an already-logged-in page.
679
699
  const tryLogin = async () => {
680
- if (await this._waitLoggedIn(3000))
700
+ if (await this._waitLoggedInOrMfa(3000))
701
+ return true;
702
+ // An MFA dialog also means the password step was ACCEPTED — stop
703
+ // submitting, the caller will ask the operator for the one-time code.
704
+ if (await this._mfaVisible())
681
705
  return true;
682
706
  return false;
683
707
  };
@@ -734,6 +758,121 @@ export class DeepSeekBrowser {
734
758
  }
735
759
  return false;
736
760
  }
761
+ /**
762
+ * Like `_waitLoggedIn`, but the input field ALSO appears right after the
763
+ * password step even when DeepSeek demands an MFA one-time code — the code
764
+ * dialog only BLOCKS the first message. A plain `_waitLoggedIn` would then
765
+ * report success, swallow the real login failure (the next send would hit the
766
+ * dialog and hang), and never ask the operator for the code. Detect the MFA
767
+ * input first and treat it as "not logged in yet".
768
+ */
769
+ async _waitLoggedInOrMfa(timeoutMs) {
770
+ const deadline = Date.now() + timeoutMs;
771
+ while (Date.now() < deadline) {
772
+ if (await this._mfaVisible())
773
+ return false;
774
+ if (await this.isLoggedIn().catch(() => false))
775
+ return true;
776
+ await this.page.waitForTimeout(500);
777
+ }
778
+ return false;
779
+ }
780
+ /** Is the email one-time-code (MFA) dialog currently shown? */
781
+ async _mfaVisible() {
782
+ return await this._findVisible(MFA_CODE_SELECTORS, 300)
783
+ .then((l) => !!l)
784
+ .catch(() => false);
785
+ }
786
+ /**
787
+ * Handle DeepSeek's email MFA dialog ("Verify your account"). It appears
788
+ * after a correct password when the account has two-step verification on. The
789
+ * code is sent by email; we ask the operator for it in the terminal and type
790
+ * it into the dialog. Returns true when the session became active, false when
791
+ * there is no dialog or the operator could not complete it.
792
+ */
793
+ async _handleMfaIfPresent() {
794
+ const codeInput = await this._findVisible(MFA_CODE_SELECTORS, 1000);
795
+ if (!codeInput)
796
+ return false;
797
+ const account = await this.page
798
+ .evaluate(readMfaAccountInDom)
799
+ .catch(() => '');
800
+ // The dialog opens with an EMPTY field: the email is only sent after
801
+ // "Send code" is clicked. Press it first (only when it is not already
802
+ // counting down), then ask the operator for the code that just arrived.
803
+ const clicked = await this._clickMfaSendCode();
804
+ console.log('\n' +
805
+ theme.warn(this._t(clicked ? 'auth.mfa_code_sent' : 'auth.mfa_required', {
806
+ v: account || '—',
807
+ })));
808
+ const readline = await import('readline');
809
+ const rl = readline.createInterface({
810
+ input: process.stdin,
811
+ output: process.stdout,
812
+ terminal: true,
813
+ });
814
+ const code = (await new Promise((resolve) => rl.question(this._t('auth.mfa_prompt'), (a) => resolve(a))))
815
+ .trim()
816
+ .replace(/\s+/g, '');
817
+ rl.close();
818
+ if (!code)
819
+ return false;
820
+ await this._setInputText(codeInput, code);
821
+ await this.page.waitForTimeout(150);
822
+ // Submit the code. Prefer the dialog's own submit button; fall back to
823
+ // Enter in the code field (the form submits on Enter).
824
+ let submitted = false;
825
+ for (const sel of MFA_SUBMIT_SELECTORS) {
826
+ const btn = this.page.locator(sel).last();
827
+ try {
828
+ if ((await btn.count()) === 0)
829
+ continue;
830
+ if (!(await btn.isVisible().catch(() => false)))
831
+ continue;
832
+ await btn.click({ timeout: 2000 });
833
+ submitted = true;
834
+ break;
835
+ }
836
+ catch { }
837
+ }
838
+ if (!submitted)
839
+ await codeInput.press('Enter').catch(() => { });
840
+ const ok = await this._waitLoggedIn(20_000);
841
+ if (ok) {
842
+ await this._persistSessionIfNeeded();
843
+ console.log(theme.assistant(this._t('auth.auto_login_ok')));
844
+ }
845
+ else {
846
+ const reason = await this._readLoginError();
847
+ console.log(theme.warn(this._t('auth.mfa_failed', {
848
+ v: reason || this._t('auth.no_reason'),
849
+ })));
850
+ }
851
+ return ok;
852
+ }
853
+ /**
854
+ * Click the dialog's "Send code" button. Returns false when it is absent or
855
+ * already counting down (a resend cooldown), so we never hammer the endpoint.
856
+ */
857
+ async _clickMfaSendCode() {
858
+ for (const sel of MFA_SEND_CODE_SELECTORS) {
859
+ const btn = this.page.locator(sel).last();
860
+ try {
861
+ if ((await btn.count()) === 0)
862
+ continue;
863
+ if (!(await btn.isVisible().catch(() => false)))
864
+ continue;
865
+ const label = ((await btn.innerText().catch(() => '')) || '').trim();
866
+ // Only click a REAL request button, never the countdown that replaces it.
867
+ if (label && !MFA_SEND_CODE_RE.test(label))
868
+ continue;
869
+ await btn.click({ timeout: 2000 });
870
+ return true;
871
+ }
872
+ catch { }
873
+ }
874
+ return false;
875
+ }
737
876
  /**
738
877
  * Persist the authenticated session. The persistent profile already keeps
739
878
  * cookies on disk; `saveSession` (config) is kept as an explicit toggle for
@@ -0,0 +1,118 @@
1
+ // The /config subcommand DISPATCH (BACKLOG C3). The PURE text rendering lives
2
+ // in config-commands.ts; this module owns the parsing/routing of the text
3
+ // subcommands (list/get/set/reset/lang/path) and prints through injected
4
+ // callbacks. Everything that touches live state (the runtime config object,
5
+ // the browser toggles, the LineEditor, the interactive menu) is injected, so
6
+ // the dispatch itself is unit-tested without a live agent.
7
+ //
8
+ // The interactive menu (`/config` bare or `menu`) is delegated to openMenu()
9
+ // because it pauses the editor and reads keys itself.
10
+ import { theme } from './theme.js';
11
+ import { configGetValue, configValueHint } from './config-commands.js';
12
+ /**
13
+ * Handle a `/config <sub>` text command. Never throws for a bad subcommand or
14
+ * argument: it prints a message and returns, so the caller can `await` it
15
+ * blindly from the main loop and from the live mid-run interception.
16
+ */
17
+ export async function handleConfigCommand(input, deps) {
18
+ const { t, print, printErr, getValue, setValue, resetField, showList, openMenu, currentLocale, normalizeLocale, localeDisplayName, homeConfigPath, projectConfigPath, } = deps;
19
+ const schema = deps.schema;
20
+ const parts = input.trim().split(/\s+/);
21
+ const sub = (parts[1] || '').toLowerCase();
22
+ // With no subcommand - the menu (or a text list in non-TTY).
23
+ if (!sub || sub === 'menu' || sub === 'ui') {
24
+ await openMenu();
25
+ return;
26
+ }
27
+ if (sub === 'list' || sub === 'show' || sub === 'ls') {
28
+ showList();
29
+ return;
30
+ }
31
+ if (sub === 'path' || sub === 'paths') {
32
+ print(theme.system(t('cfg.paths', { global: homeConfigPath, project: projectConfigPath })));
33
+ return;
34
+ }
35
+ // /config lang <ru|en> - quick access to ui.locale
36
+ if (sub === 'lang' || sub === 'language' || sub === 'язык') {
37
+ const val = parts[2];
38
+ if (!val) {
39
+ print(theme.system(t('status.locale', { v: localeDisplayName(currentLocale()) })));
40
+ print(theme.dim(t('cfg.lang_usage')));
41
+ return;
42
+ }
43
+ const field = schema.find((f) => f.path === 'ui.locale');
44
+ if (!field)
45
+ return;
46
+ const loc = normalizeLocale(val);
47
+ try {
48
+ setValue(field, loc);
49
+ }
50
+ catch (e) {
51
+ printErr(theme.error(t('cfg.write_error', { v: e.message })));
52
+ return;
53
+ }
54
+ print(theme.assistant(t('cfg.lang_set', { v: localeDisplayName(loc) })));
55
+ return;
56
+ }
57
+ if (sub === 'get') {
58
+ const key = parts[2];
59
+ if (!key) {
60
+ print(theme.dim(t('cfg.usage')));
61
+ return;
62
+ }
63
+ const field = schema.find((f) => f.path === key);
64
+ if (!field) {
65
+ printErr(theme.error(t('cfg.unknown_key', { v: key })));
66
+ return;
67
+ }
68
+ const cur = getValue(key);
69
+ print(theme.system(t('cfg.value', { v: key, value: configGetValue(key, cur, t) })));
70
+ return;
71
+ }
72
+ if (sub === 'set') {
73
+ const key = parts[2];
74
+ const raw = parts.slice(3).join(' ');
75
+ const field = schema.find((f) => f.path === key);
76
+ if (!field) {
77
+ printErr(theme.error(t('cfg.unknown_key', { v: key || '' })));
78
+ return;
79
+ }
80
+ if (!raw) {
81
+ print(theme.dim(t('cfg.usage')));
82
+ return;
83
+ }
84
+ try {
85
+ setValue(field, raw);
86
+ }
87
+ catch {
88
+ printErr(theme.error(t('cfg.bad_value', { v: key, type: configValueHint(field, t) })));
89
+ return;
90
+ }
91
+ print(theme.assistant(t('cfg.saved', {
92
+ v: key,
93
+ value: JSON.stringify(getValue(key)),
94
+ file: homeConfigPath,
95
+ })));
96
+ return;
97
+ }
98
+ if (sub === 'reset' || sub === 'unset') {
99
+ const key = parts[2];
100
+ const field = schema.find((f) => f.path === key);
101
+ if (!field) {
102
+ printErr(theme.error(t('cfg.unknown_key', { v: key || '' })));
103
+ return;
104
+ }
105
+ try {
106
+ resetField(field);
107
+ }
108
+ catch (e) {
109
+ printErr(theme.error(t('cfg.write_error', { v: e.message })));
110
+ return;
111
+ }
112
+ print(theme.assistant(t('cfg.reset', { v: key })));
113
+ return;
114
+ }
115
+ // Unknown subcommand - show the list.
116
+ printErr(theme.error(t('cfg.unknown_key', { v: sub })));
117
+ showList();
118
+ }
package/dist/context.js CHANGED
@@ -58,25 +58,45 @@ const BUILTIN_SKILLS = [
58
58
  '4. Do NOT change code. Answer in the operator language.',
59
59
  },
60
60
  ];
61
- const MAX_FILE_BASE = 60000;
62
- const MAX_TOTAL = 240000;
61
+ // Per-file budgets. AGENTS.md/MEMORY.md are hand-maintained and grow over
62
+ // time, so the caps are generous: an AGENTS.md of a real project (this repo's
63
+ // own file is ~69k) must NOT be silently cut. The old 60k cap already
64
+ // truncated it. Set higher and split per kind so a huge AGENTS.md can no
65
+ // longer starve MEMORY.md of the shared pool (the old single pool filled on
66
+ // agents first, so memory was dropped WHOLE when agents were large).
67
+ export const MAX_FILE_BASE = 120000;
68
+ /** MEMORY.md is append-only, so it gets its own (larger) budget. */
69
+ export const MAX_MEMORY_FILE = 200000;
70
+ /** Total spent on the AGENTS.md chain (project instructions + scoped). */
71
+ const MAX_AGENTS_TOTAL = 240000;
72
+ /** Total spent on the MEMORY.md chain, independent of the agents pool. */
73
+ const MAX_MEMORY_TOTAL = 200000;
63
74
  const CRLF_RE = /\r\n/g;
64
75
  const NL = String.fromCharCode(10);
65
- function clipIf(name, content, budget) {
76
+ function clipIf(name, content, budget, fromTail = false) {
66
77
  const cleaned = content.replace(CRLF_RE, NL).trim();
67
78
  if (cleaned.length <= budget)
68
79
  return cleaned;
69
80
  const keep = Math.max(0, budget - 120);
70
- return (cleaned.slice(0, keep) +
71
- NL +
72
- NL +
73
- '[... ' +
81
+ const marker = '[... ' +
74
82
  name +
75
83
  ' truncated (' +
76
84
  (cleaned.length - keep) +
77
- ' chars omitted) ...]');
85
+ ' chars omitted) ...]';
86
+ // MEMORY.md is APPENDED to over time, so its newest (most relevant) notes
87
+ // live at the TAIL — a head clip would drop exactly those. Keep the tail
88
+ // for memory files, the head for AGENTS.md (whose top is the summary).
89
+ if (!fromTail)
90
+ return cleaned.slice(0, keep) + NL + NL + marker;
91
+ let slice = cleaned.slice(cleaned.length - keep);
92
+ // Cut at a NOTE boundary, not mid-note: start the kept tail at the next line
93
+ // that begins a bullet or a heading, so a half-sentence note is not left.
94
+ const m = slice.search(/\n(?=[-*] |#{1,6} )/);
95
+ if (m > 0)
96
+ slice = slice.slice(m + 1);
97
+ return marker + NL + NL + slice;
78
98
  }
79
- async function readIfFile(p, budget) {
99
+ async function readIfFile(p, budget, fromTail = false) {
80
100
  try {
81
101
  const st = await fs.stat(p);
82
102
  if (!st.isFile())
@@ -85,7 +105,7 @@ async function readIfFile(p, budget) {
85
105
  // An EXISTING but empty file is still present: keep it so the prompt
86
106
  // shows an (empty) AGENTS.md/MEMORY.md section instead of silently
87
107
  // dropping the file. Only a read failure returns null.
88
- const content = clipIf(path.basename(p), raw, budget);
108
+ const content = clipIf(path.basename(p), raw, budget, fromTail);
89
109
  return { path: p, content };
90
110
  }
91
111
  catch {
@@ -367,7 +387,8 @@ async function loadMemoryChain(workdir) {
367
387
  path.join(os.homedir(), '.zames', 'MEMORY.md'),
368
388
  path.join(os.homedir(), '.claude', 'MEMORY.md'),
369
389
  ]) {
370
- const f = await readIfFile(p, MAX_FILE_BASE);
390
+ // Memory is append-only, so clip from the TAIL to keep the newest notes.
391
+ const f = await readIfFile(p, MAX_MEMORY_FILE, true);
371
392
  if (f && !seen.has(f.path)) {
372
393
  seen.add(f.path);
373
394
  out.push(f);
@@ -378,7 +399,7 @@ async function loadMemoryChain(workdir) {
378
399
  const p = await findNamedFile(dir, MEMORY_NAMES);
379
400
  if (!p || seen.has(p))
380
401
  continue;
381
- const f = await readIfFile(p, MAX_FILE_BASE);
402
+ const f = await readIfFile(p, MAX_MEMORY_FILE, true);
382
403
  if (f) {
383
404
  seen.add(p);
384
405
  out.push(f);
@@ -394,24 +415,31 @@ export async function loadProjectContext(workdir, touchPaths = []) {
394
415
  loadCommands(workdir).catch(() => []),
395
416
  ]);
396
417
  const scopedAgents = await loadScopedAgents(workdir, touchPaths).catch(() => []);
397
- let used = 0;
398
- const withinBudget = (files) => {
418
+ // SEPARATE pools for agents and memory: the old single pool filled on the
419
+ // agents chain first, so a large AGENTS.md dropped MEMORY.md WHOLE. Each
420
+ // category now has its own budget, and memory can never be starved by
421
+ // project instructions (or vice versa).
422
+ const takeWithin = (files, total) => {
423
+ let used = 0;
399
424
  const kept = [];
400
425
  for (const f of files) {
401
- if (used + f.content.length > MAX_TOTAL)
426
+ if (used + f.content.length > total)
402
427
  break;
403
428
  used += f.content.length;
404
429
  kept.push(f);
405
430
  }
406
431
  return kept;
407
432
  };
433
+ const keptAgents = takeWithin(agents, MAX_AGENTS_TOTAL);
434
+ const agentsUsed = keptAgents.reduce((n, f) => n + f.content.length, 0);
435
+ const keptScoped = takeWithin(scopedAgents, MAX_AGENTS_TOTAL - agentsUsed);
408
436
  return {
409
- agents: withinBudget(agents),
410
- // Scoped files are dropped silently when the total budget is already spent
437
+ agents: keptAgents,
438
+ // Scoped files are dropped silently when the AGENTS pool is already spent
411
439
  // on the main chain: a nested AGENTS.md is an optimization, not a hard
412
440
  // requirement, and must never push out a top-level instruction.
413
- scopedAgents: withinBudget(scopedAgents),
414
- memory: withinBudget(memory),
441
+ scopedAgents: keptScoped,
442
+ memory: takeWithin(memory, MAX_MEMORY_TOTAL),
415
443
  skills,
416
444
  commands,
417
445
  };
@@ -164,6 +164,39 @@ export const LOGIN_SUBMIT_SELECTORS = [
164
164
  'div[role="button"]:has-text("Sign in")',
165
165
  'div[role="button"]:has-text("Войти")',
166
166
  ];
167
+ // ---------- MFA / email verification ----------
168
+ //
169
+ // After a correct password DeepSeek may demand a one-time code sent to the
170
+ // account email (the `.ds-mfa-verification-modal` dialog). The code input is
171
+ // the reliable anchor: `autocomplete=one-time-code` is a semantic attribute
172
+ // that survives class renames, so detection does not depend on DeepSeek's CSS.
173
+ // The submit button is matched both by its own class and by its label, since
174
+ // the modal is a newer component whose classes we cannot pin down.
175
+ export const MFA_CODE_SELECTORS = [
176
+ 'input[autocomplete="one-time-code"]',
177
+ '.ds-mfa-verification-modal input[type="tel"]',
178
+ 'input[inputmode="numeric"][maxlength="6"]',
179
+ ];
180
+ export const MFA_MODAL_SELECTOR = '.ds-mfa-verification-modal, [role="dialog"]';
181
+ export const MFA_SUBMIT_SELECTORS = [
182
+ 'div[role="button"].ds-mfa-verification-submit',
183
+ '.ds-mfa-verification-modal div[role="button"].ds-button--primary',
184
+ 'div[role="button"]:has-text("Verify and log in")',
185
+ 'div[role="button"]:has-text("Verify")',
186
+ 'div[role="button"]:has-text("Подтвердить")',
187
+ 'div[role="button"]:has-text("Войти")',
188
+ ];
189
+ // The email is NOT sent until the operator clicks "Send code" — the dialog only
190
+ // shows an empty field and a request button. The button lives inside the code
191
+ // field suffix and turns into a countdown ("Resend in 60s") once clicked, so we
192
+ // match it by its TEXT and skip it when it is already counting down.
193
+ export const MFA_SEND_CODE_RE = /^(send code|resend|отправить\s*код|отправить\s*повторно|выслать\s*код)\s*$/i;
194
+ export const MFA_SEND_CODE_SELECTORS = [
195
+ '.ds-verify-code-input-countdown',
196
+ '.ds-mfa-verification-modal div[role="button"]',
197
+ 'div[role="button"]:has-text("Send code")',
198
+ 'div[role="button"]:has-text("Отправить код")',
199
+ ];
167
200
  // ---------- sidebar / chats ----------
168
201
  export const SIDEBAR_TOGGLE_SELECTORS = [
169
202
  'button[aria-label*="sidebar" i]',
@@ -29,10 +29,6 @@ export const commonMessages = {
29
29
  ru: 'System prompt на след. задаче: {v}',
30
30
  en: 'System prompt next task: {v}',
31
31
  },
32
- 'status.resend': {
33
- ru: 'Resend prompt (--resend-prompt): {v}',
34
- en: 'Resend prompt (--resend-prompt): {v}',
35
- },
36
32
  'status.last_chat': { ru: 'Last chat: {v}', en: 'Last chat: {v}' },
37
33
  'status.sessions': { ru: 'Сессии: {v}', en: 'Sessions: {v}' },
38
34
  'status.dev': {
@@ -145,14 +141,6 @@ export const commonMessages = {
145
141
  ru: 'Транскрипт: {transcript} · чат: {chat}',
146
142
  en: 'Transcript: {transcript} · chat: {chat}',
147
143
  },
148
- 'msg.abort_gen': {
149
- ru: '⏹ Esc — прерываю генерацию...',
150
- en: '⏹ Esc — aborting generation...',
151
- },
152
- 'msg.abort_ctrlc': {
153
- ru: '⏹ Ctrl+C — прерываю генерацию...',
154
- en: '⏹ Ctrl+C — aborting generation...',
155
- },
156
144
  'msg.queued': { ru: '📨 В очередь ({n}): ', en: '📨 Queued ({n}): ' },
157
145
  // TTY variant: the editor already echoed the message text, so only the
158
146
  // count is shown (no repeated text).
@@ -301,10 +289,6 @@ export const commonMessages = {
301
289
  en: 'undo is disabled in the config',
302
290
  },
303
291
  'rewind.empty': { ru: 'Чекпойнтов нет.', en: 'No checkpoints.' },
304
- 'rewind.created': {
305
- ru: '📸 Чекпойнт: {v}',
306
- en: '📸 Checkpoint: {v}',
307
- },
308
292
  'rewind.list_title': { ru: 'Чекпойнты:', en: 'Checkpoints:' },
309
293
  'rewind.reverted': {
310
294
  ru: '↶ Откатили рабочее дерево к чекпойнту {v}',
@@ -148,7 +148,6 @@ export const configMessages = {
148
148
  en: 'Persist the login session in the profile',
149
149
  },
150
150
  'cfg.title': { ru: 'Настройки zames', en: 'zames settings' },
151
- 'cfg.current': { ru: 'Текущий конфиг:', en: 'Current config:' },
152
151
  'cfg.usage': {
153
152
  ru: 'Использование: /config [menu | list | get <путь> | set <путь> <значение> | reset <путь> | path | lang <ru|en>]',
154
153
  en: 'Usage: /config [menu | list | get <path> | set <path> <value> | reset <path> | path | lang <ru|en>]',
@@ -179,7 +178,6 @@ export const configMessages = {
179
178
  ru: 'Конфиг (глобальный): {global}\nКонфиг (проект): {project}',
180
179
  en: 'Config (global): {global}\nConfig (project): {project}',
181
180
  },
182
- 'cfg.keys': { ru: 'Доступные параметры:', en: 'Available settings:' },
183
181
  'cfg.lang_set': {
184
182
  ru: 'Язык переключён на {v}.',
185
183
  en: 'Language switched to {v}.',
@@ -188,20 +186,4 @@ export const configMessages = {
188
186
  ru: 'Использование: /config lang <ru|en>',
189
187
  en: 'Usage: /config lang <ru|en>',
190
188
  },
191
- 'cfg.scope_current': {
192
- ru: 'Запись конфига: {v} ({file})',
193
- en: 'Config write scope: {v} ({file})',
194
- },
195
- 'cfg.scope_usage': {
196
- ru: 'Использование: /config scope <project|home>. project = .zamesrc.json (в git), home = ~/.zames/. Для личных настроек выбери home, чтобы не коммитить их.',
197
- en: 'Usage: /config scope <project|home>. project = .zamesrc.json (committed), home = ~/.zames/. Use home for personal settings so they are not committed.',
198
- },
199
- 'cfg.scope_set': {
200
- ru: 'Запись конфига: {v} ({file})',
201
- en: 'Config write scope: {v} ({file})',
202
- },
203
- 'cfg.scope_bad': {
204
- ru: 'Неизвестный scope: {v}. Используй project или home.',
205
- en: 'Unknown scope: {v}. Use project or home.',
206
- },
207
189
  };
@@ -88,10 +88,6 @@ export const deepseekMessages = {
88
88
  ru: '⏳ Агент не распознал ответ модели — прошу продолжить ({attempt}/{max})...',
89
89
  en: '⏳ The agent did not recognize the model answer — asking it to continue ({attempt}/{max})...',
90
90
  },
91
- 'ds.stalled': {
92
- ru: '⚠ Агент остановился, не завершив задачу (модель перестала вызывать инструменты). Проверь чат DeepSeek — задача может быть не выполнена.',
93
- en: '⚠ The agent stopped before finishing (the model stopped calling tools). Check the DeepSeek chat — the task may be incomplete.',
94
- },
95
91
  'ds.not_launched': {
96
92
  ru: '✖ Браузер не запущен.',
97
93
  en: '✖ The browser is not launched.',
@@ -145,17 +141,21 @@ export const deepseekMessages = {
145
141
  ru: 'Войдите в DeepSeek: откройте https://chat.deepseek.com/ в своём браузере. Если включён headless — задайте логин/пароль через /config или запустите с --headed.',
146
142
  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.',
147
143
  },
148
- 'auth.session_saved': {
149
- ru: 'Сессия DeepSeek сохранена.',
150
- en: 'DeepSeek session saved.',
144
+ 'auth.mfa_required': {
145
+ ru: '🔐 DeepSeek требует код подтверждения на почту {v}.',
146
+ en: '🔐 DeepSeek requires a verification code sent to {v}.',
147
+ },
148
+ 'auth.mfa_code_sent': {
149
+ ru: '🔐 DeepSeek требует код подтверждения. Запросил код на почту {v} — проверь почту.',
150
+ en: '🔐 DeepSeek requires a verification code. Requested a code to {v} — check your inbox.',
151
151
  },
152
- 'auth.session_restored': {
153
- ru: 'Сессия DeepSeek восстановлена.',
154
- en: 'DeepSeek session restored.',
152
+ 'auth.mfa_prompt': {
153
+ ru: 'Код из письма: ',
154
+ en: 'Code from the email: ',
155
155
  },
156
- 'auth.enter_to_continue': {
157
- ru: 'Нажмите Enter, чтобы продолжить...',
158
- en: 'Press Enter to continue...',
156
+ 'auth.mfa_failed': {
157
+ ru: 'Код подтверждения не принят: {v}',
158
+ en: 'The verification code was not accepted: {v}',
159
159
  },
160
160
  'cfg.menu.title': {
161
161
  ru: '⚙ Настройки — выбери параметр',
@@ -127,10 +127,6 @@ export const doctorMessages = {
127
127
  ru: 'Часть модулей не перезагрузилась:',
128
128
  en: 'Some modules failed to reload:',
129
129
  },
130
- 'msg.reload_done': {
131
- ru: 'Перезагружено модулей: {n}. Браузер и чат не тронуты.',
132
- en: 'Reloaded modules: {n}. Browser and chat untouched.',
133
- },
134
130
  'msg.reload_error': { ru: 'Ошибка reload:', en: 'Reload error:' },
135
131
  'msg.abort_gen_short': {
136
132
  ru: '⏹ Esc — прерываю генерацию...',
package/dist/i18n/help.js CHANGED
@@ -377,10 +377,6 @@ export const helpMessages = {
377
377
  ru: '🗜️ Сжимаю историю чата (DeepSeek)...',
378
378
  en: '🗜️ Compacting the chat history (DeepSeek)...',
379
379
  },
380
- 'compact.empty': {
381
- ru: 'Нечего сжимать: в чате ещё нет ответов.',
382
- en: 'Nothing to compact: the chat has no answers yet.',
383
- },
384
380
  'compact.summary_failed': {
385
381
  ru: 'Не удалось получить резюме от модели: {v}',
386
382
  en: 'Could not get the summary from the model: {v}',
@@ -461,10 +457,6 @@ export const helpMessages = {
461
457
  ru: '🧠 Заметка добавлена в {v}',
462
458
  en: '🧠 Note appended to {v}',
463
459
  },
464
- 'init.exists': {
465
- ru: 'AGENTS.md уже существует: {v}',
466
- en: 'AGENTS.md already exists: {v}',
467
- },
468
460
  'init.created': { ru: 'Создан {v}', en: 'Created {v}' },
469
461
  'init.analyzing': {
470
462
  ru: 'Изучаю проект и готовлю AGENTS.md...',
@@ -4,10 +4,6 @@ export const promptMessages = {
4
4
  ru: 'ВАЖНО: отвечай оператору на русском языке. Весь текст в поле message инструмента respond, а также любые пояснения — на русском.',
5
5
  en: 'IMPORTANT: reply to the operator in English. All text in the respond tool message field, and any explanations, must be in English.',
6
6
  },
7
- 'prompt.tools_header': {
8
- ru: 'You have access to the following tools:',
9
- en: 'You have access to the following tools:',
10
- },
11
7
  // The hard "ONLY TOOL CALLS" block. All prose around a tool call is a
12
8
  // protocol violation: the operator never sees it (only tool calls and the
13
9
  // final respond reach the terminal), so it is pure pollution. We cannot
@@ -112,7 +112,6 @@ export const selfReviewMessages = {
112
112
  ru: '\nИспользуй /resume <n> для продолжения.\n',
113
113
  en: '\nUse /resume <n> to continue.\n',
114
114
  },
115
- 'chats.none': { ru: 'Чатов не найдено.', en: 'No chats found.' },
116
115
  'chats.none_hint': {
117
116
  ru: 'Чатов не найдено. Возможно, сайдбар свёрнут или селекторы устарели.',
118
117
  en: 'No chats found. The sidebar may be collapsed or the selectors are outdated.',
@@ -142,10 +141,6 @@ export const selfReviewMessages = {
142
141
  en: 'No chat #{n}. Total: {total}.',
143
142
  },
144
143
  'chats.opening': { ru: 'Открываю: {v}', en: 'Opening: {v}' },
145
- 'chats.context_kept': {
146
- ru: ' Контекст чата сохранён. Системный промпт будет переслан на следующей задаче.\n',
147
- en: ' Chat context kept. The system prompt will be resent on the next task.\n',
148
- },
149
144
  'chats.prompt_will_resend': {
150
145
  ru: ' Системный промпт будет переслан на следующей задаче.\n',
151
146
  en: ' System prompt will be resent on the next task.\n',
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { existsSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
- import { createTools, mergeTools, filterToolsForReadOnly } from './tools.js';
9
+ import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
10
10
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
11
11
  import { runAgentLoop } from './agent-loop.js';
12
12
  import { createSubagentRunner } from './subagent.js';
@@ -16,7 +16,8 @@ import { promptOnce } from './prompt.js';
16
16
  import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
17
17
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
18
18
  import { runConfigMenu } from './config-menu.js';
19
- import { configGetValue, configValueHint, formatConfigList, } from './config-commands.js';
19
+ import { formatConfigList } from './config-commands.js';
20
+ import { handleConfigCommand as runConfigCommand } from './config-command.js';
20
21
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
21
22
  import { Transcript } from './transcript.js';
22
23
  import { UndoStore } from './undo.js';
@@ -31,7 +32,7 @@ import { parseBacklogNote } from './backlog.js';
31
32
  import { askOperatorConfirm, } from './run-task.js';
32
33
  import { closeWeb } from './web.js';
33
34
  import { assertCommandInsideRoot, runShell, killAllBackground, } from './shell.js';
34
- import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
35
+ import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, isInternalChat, } from './sessions.js';
35
36
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
37
  const NL = String.fromCharCode(10);
37
38
  // The last final assistant message, for the /copy command. Module-level
@@ -536,7 +537,15 @@ async function expandSlashTarget(workdir, name, rest) {
536
537
  const extra = rest
537
538
  ? '\n\nAdditional instructions from the operator: ' + rest
538
539
  : '';
539
- return { text: header + '\n\n' + body + extra };
540
+ // A skill may declare allowed-tools (Claude Code frontmatter): the
541
+ // operator expects the skill to run with ONLY those tools. Carry the
542
+ // list to the task so the main loop can narrow the tool set.
543
+ return {
544
+ text: header + '\n\n' + body + extra,
545
+ allowedTools: skill.allowedTools && skill.allowedTools.length
546
+ ? skill.allowedTools
547
+ : undefined,
548
+ };
540
549
  }
541
550
  }
542
551
  catch {
@@ -1574,108 +1583,22 @@ async function main() {
1574
1583
  }
1575
1584
  }
1576
1585
  async function handleConfigCommand(input) {
1577
- const parts = input.trim().split(/\s+/);
1578
- const sub = (parts[1] || '').toLowerCase();
1579
- // With no subcommand — the menu (or a text list in non-TTY).
1580
- if (!sub || sub === 'menu' || sub === 'ui') {
1581
- await configOpenMenu();
1582
- return;
1583
- }
1584
- if (sub === 'list' || sub === 'show' || sub === 'ls') {
1585
- configShowList();
1586
- return;
1587
- }
1588
- if (sub === 'path' || sub === 'paths') {
1589
- console.log(theme.system(t('cfg.paths', {
1590
- global: CONFIG_PATHS.HOME_CONFIG,
1591
- project: CONFIG_PATHS.PROJECT_CONFIG,
1592
- })));
1593
- return;
1594
- }
1595
- // /config lang <ru|en> — quick access to ui.locale
1596
- if (sub === 'lang' || sub === 'language' || sub === 'язык') {
1597
- const val = parts[2];
1598
- if (!val) {
1599
- console.log(theme.system(t('status.locale', { v: localeDisplayName(currentLocale) })));
1600
- console.log(theme.dim(t('cfg.lang_usage')));
1601
- return;
1602
- }
1603
- const field = CONFIG_SCHEMA.find((f) => f.path === 'ui.locale');
1604
- const loc = normalizeLocale(val);
1605
- try {
1606
- configSetRaw(field, loc);
1607
- }
1608
- catch (e) {
1609
- console.error(theme.error(t('cfg.write_error', { v: e.message })));
1610
- return;
1611
- }
1612
- console.log(theme.assistant(t('cfg.lang_set', { v: localeDisplayName(loc) })));
1613
- return;
1614
- }
1615
- if (sub === 'get') {
1616
- const key = parts[2];
1617
- if (!key) {
1618
- console.log(theme.dim(t('cfg.usage')));
1619
- return;
1620
- }
1621
- const field = CONFIG_SCHEMA.find((f) => f.path === key);
1622
- if (!field) {
1623
- console.error(theme.error(t('cfg.unknown_key', { v: key })));
1624
- return;
1625
- }
1626
- const cur = getByPath(config, key);
1627
- console.log(theme.system(t('cfg.value', { v: key, value: configGetValue(key, cur, t) })));
1628
- return;
1629
- }
1630
- if (sub === 'set') {
1631
- const key = parts[2];
1632
- const raw = parts.slice(3).join(' ');
1633
- const field = CONFIG_SCHEMA.find((f) => f.path === key);
1634
- if (!field) {
1635
- console.error(theme.error(t('cfg.unknown_key', { v: key || '' })));
1636
- return;
1637
- }
1638
- if (!raw) {
1639
- console.log(theme.dim(t('cfg.usage')));
1640
- return;
1641
- }
1642
- try {
1643
- configSetRaw(field, raw);
1644
- }
1645
- catch {
1646
- console.error(theme.error(t('cfg.bad_value', {
1647
- v: key,
1648
- type: configValueHint(field, t),
1649
- })));
1650
- return;
1651
- }
1652
- console.log(theme.assistant(t('cfg.saved', {
1653
- v: key,
1654
- value: JSON.stringify(getByPath(config, key)),
1655
- file: CONFIG_PATHS.HOME_CONFIG,
1656
- })));
1657
- return;
1658
- }
1659
- if (sub === 'reset' || sub === 'unset') {
1660
- const key = parts[2];
1661
- const field = CONFIG_SCHEMA.find((f) => f.path === key);
1662
- if (!field) {
1663
- console.error(theme.error(t('cfg.unknown_key', { v: key || '' })));
1664
- return;
1665
- }
1666
- try {
1667
- configResetField(field);
1668
- }
1669
- catch (e) {
1670
- console.error(theme.error(t('cfg.write_error', { v: e.message })));
1671
- return;
1672
- }
1673
- console.log(theme.assistant(t('cfg.reset', { v: key })));
1674
- return;
1675
- }
1676
- // Unknown subcommand — show the list.
1677
- console.error(theme.error(t('cfg.unknown_key', { v: sub })));
1678
- configShowList();
1586
+ await runConfigCommand(input, {
1587
+ t,
1588
+ print: (line) => console.log(line),
1589
+ printErr: (line) => console.error(line),
1590
+ getValue: (p) => getByPath(config, p),
1591
+ setValue: (field, raw) => configSetRaw(field, raw),
1592
+ resetField: (field) => configResetField(field),
1593
+ showList: () => configShowList(),
1594
+ openMenu: () => configOpenMenu(),
1595
+ currentLocale: () => currentLocale,
1596
+ normalizeLocale,
1597
+ localeDisplayName,
1598
+ schema: CONFIG_SCHEMA,
1599
+ homeConfigPath: CONFIG_PATHS.HOME_CONFIG,
1600
+ projectConfigPath: CONFIG_PATHS.PROJECT_CONFIG,
1601
+ });
1679
1602
  }
1680
1603
  while (running) {
1681
1604
  let input;
@@ -1975,7 +1898,11 @@ async function main() {
1975
1898
  editor.lock(t('msg.input_locked'));
1976
1899
  spin.thinking();
1977
1900
  try {
1978
- lastChats = await browser.listChats(30);
1901
+ const all = await browser.listChats(30);
1902
+ // Hide subagent chats: they are real DeepSeek chats, but they are
1903
+ // internal context-isolation artifacts whose report already lives in
1904
+ // the parent chat, so resuming one is never what the operator wants.
1905
+ lastChats = all.filter((c) => !isInternalChat(c.id));
1979
1906
  spin.stop();
1980
1907
  // An optional query filters by title (substring, case-insensitive),
1981
1908
  // so a long chat list does not have to be scanned by eye.
@@ -3145,14 +3072,19 @@ async function main() {
3145
3072
  // prompt templates with $ARGUMENTS / {{args}} placeholders.
3146
3073
  let expandedTask = null;
3147
3074
  let missingArgs = null;
3075
+ let skillAllowedTools = null;
3148
3076
  if (lower.startsWith('/')) {
3149
3077
  const name = trimmed.slice(1).split(/\s+/)[0];
3150
3078
  const rest = trimmed.slice(1 + name.length).trim();
3151
3079
  const res = await expandSlashTarget(currentWorkdir, name, rest);
3152
3080
  if (res && 'missing' in res)
3153
3081
  missingArgs = res.missing;
3154
- else if (res)
3082
+ else if (res) {
3155
3083
  expandedTask = res.text;
3084
+ if ('allowedTools' in res && res.allowedTools) {
3085
+ skillAllowedTools = res.allowedTools;
3086
+ }
3087
+ }
3156
3088
  }
3157
3089
  // B7: a custom command with mandatory `arguments:` refuses to run until the
3158
3090
  // operator supplies them (by position).
@@ -3209,7 +3141,13 @@ async function main() {
3209
3141
  // plan (read-only) mode — a `general` subagent could write otherwise.
3210
3142
  subagents: config.browser.subagents && !planMode,
3211
3143
  });
3212
- const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
3144
+ let tools = mergeTools(baseTools, mcpPool?.tools, planMode);
3145
+ // A skill invoked via /skill with `allowed-tools:` narrows the tool set to
3146
+ // that allow-list (Claude Code contract). respond is always kept so the run
3147
+ // can still finish.
3148
+ if (skillAllowedTools) {
3149
+ tools = filterToolsByAllowList(tools, skillAllowedTools);
3150
+ }
3213
3151
  if (editor)
3214
3152
  editor.busy = true;
3215
3153
  try {
package/dist/sessions.js CHANGED
@@ -9,6 +9,40 @@ import { writeJsonAtomic } from './fsutil.js';
9
9
  const SESSIONS_DIR = path.join(os.homedir(), '.zames', '.sessions');
10
10
  const INDEX_FILE = path.join(SESSIONS_DIR, 'last.json');
11
11
  export const SESSIONS_FORMAT_VERSION = 1;
12
+ // Subagent chats are real DeepSeek chats, but they are INTERNAL: they exist
13
+ // only to isolate a sub-task's context and their report is already folded back
14
+ // into the parent chat. They must never be resumable. DeepSeek's own sidebar
15
+ // (`/chats`, which scrapes the DOM) still shows them under auto-generated
16
+ // titles, so we cannot hide them there; but we CAN hide them from `/sessions`
17
+ // (our own store) by keeping an id denylist here and filtering every read path.
18
+ const INTERNAL_FILE = path.join(SESSIONS_DIR, 'internal.json');
19
+ function readInternal() {
20
+ try {
21
+ const raw = JSON.parse(fs.readFileSync(INTERNAL_FILE, 'utf-8'));
22
+ return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
23
+ }
24
+ catch {
25
+ return new Set();
26
+ }
27
+ }
28
+ /** Remember a chat id as internal (a subagent chat) so it is never resumed. */
29
+ export function markInternalChat(id) {
30
+ if (!id)
31
+ return;
32
+ try {
33
+ ensureDir();
34
+ const set = readInternal();
35
+ set.add(String(id));
36
+ writeJsonAtomic(INTERNAL_FILE, Array.from(set));
37
+ }
38
+ catch { }
39
+ }
40
+ /** True when `id` is a known internal (subagent) chat. */
41
+ export function isInternalChat(id) {
42
+ if (!id)
43
+ return false;
44
+ return readInternal().has(String(id));
45
+ }
12
46
  function ensureDir() {
13
47
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
14
48
  }
@@ -78,9 +112,12 @@ export function loadLastSession(workdir = '') {
78
112
  const index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'));
79
113
  const byWorkdir = index && index.byWorkdir ? index.byWorkdir : {};
80
114
  const candidates = [byWorkdir[workdir], index.last].filter((x) => Boolean(x));
115
+ const internal = readInternal();
81
116
  for (const id of candidates) {
82
117
  // The session file may have been deleted — the index is then stale and
83
118
  // the session cannot be restored. We try the next candidate.
119
+ if (internal.has(id))
120
+ continue;
84
121
  const s = readSession(id);
85
122
  if (s)
86
123
  return s;
@@ -102,18 +139,19 @@ export function readSession(id) {
102
139
  return null;
103
140
  }
104
141
  }
105
- // List all sessions, newest first.
142
+ // List all sessions, newest first. Internal (subagent) chats are excluded.
106
143
  export function listSessions() {
107
144
  try {
108
145
  ensureDir();
146
+ const internal = readInternal();
109
147
  const files = fs
110
148
  .readdirSync(SESSIONS_DIR)
111
- .filter((f) => f.endsWith('.json') && f !== 'last.json');
149
+ .filter((f) => f.endsWith('.json') && f !== 'last.json' && f !== 'internal.json');
112
150
  const out = [];
113
151
  for (const f of files) {
114
152
  try {
115
153
  const data = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf-8'));
116
- if (data && data.id)
154
+ if (data && data.id && !internal.has(String(data.id)))
117
155
  out.push(data);
118
156
  }
119
157
  catch { }
package/dist/subagent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadHooks, runLifecycleHooks } from './hooks.js';
2
+ import { markInternalChat } from './sessions.js';
2
3
  // Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
3
4
  // by the agent loop as a SEAM (like onAutoCompact): it calls back into the
4
5
  // caller, which owns the whole lifecycle. This module builds that callback.
@@ -69,7 +70,27 @@ export function createSubagentRunner(opts) {
69
70
  };
70
71
  }
71
72
  used++;
72
- const parentChatId = await browser.getCurrentChatId().catch(() => null);
73
+ // Resolve the parent chat id BEFORE anything else. The runner restores the
74
+ // parent chat in its finally block, and that restore is what keeps the
75
+ // subagent's isolated context from swallowing the parent's turn. If the id
76
+ // cannot be resolved we must NOT run the nested loop: without it the finally
77
+ // would skip openChat and the parent would continue INSIDE the subagent
78
+ // chat. A couple of quick retries cover the "id not known yet" window on a
79
+ // freshly opened chat; after that we fail safe and tell the model.
80
+ let parentChatId = null;
81
+ for (let attempt = 0; attempt < 3 && !parentChatId; attempt++) {
82
+ parentChatId = await browser.getCurrentChatId().catch(() => null);
83
+ if (!parentChatId)
84
+ await new Promise((r) => setTimeout(r, 300));
85
+ }
86
+ if (!parentChatId) {
87
+ return {
88
+ ok: false,
89
+ text: 'Cannot start a subagent: the current chat id is unknown, so the ' +
90
+ 'parent chat could not be restored afterwards (context isolation ' +
91
+ 'would be lost). Do the work yourself with the other tools.',
92
+ };
93
+ }
73
94
  const subTools = buildTools(req.type === 'explore');
74
95
  const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
75
96
  // Save the parent's send hooks: the nested loop OVERWRITES them on
@@ -104,8 +125,12 @@ export function createSubagentRunner(opts) {
104
125
  selfImprovement: false,
105
126
  // No recursion: the subagent cannot spawn its own subagents.
106
127
  onSubagent: null,
107
- // Do not persist the subagent chat as a session.
108
- onChatReady: () => { },
128
+ // Do not persist the subagent chat as a session, but REMEMBER its id
129
+ // as internal so it is hidden from /sessions and never resumed.
130
+ onChatReady: (chatId) => {
131
+ if (chatId)
132
+ markInternalChat(chatId);
133
+ },
109
134
  // Keep the PARENT's UI alive during the nested run. The nested loop
110
135
  // OVERWRITES browser.onSendStart/… with its own callbacks; without
111
136
  // forwarding the parent's hooks the spinner and the throttle countdown
@@ -34,14 +34,18 @@ its OWN separate chat with an ISOLATED context and returns ONLY a final
34
34
  report. The subagent does NOT see this conversation, so the prompt you pass
35
35
  must be fully self-contained (goal, exact questions, file paths, context).
36
36
 
37
- Use it to PROTECT this chat's context and to parallelize independent work:
37
+ Use it to PROTECT this chat's context — that is its ONLY purpose. zames
38
+ runs subagents SEQUENTIALLY (one browser, one shared send slot), so a Task
39
+ call does NOT speed anything up; it only keeps this chat's context small.
38
40
 
39
41
  - Broad exploration whose raw hits would flood this chat ("find every use of
40
42
  X", "map how module Y works end to end"): delegate it with
41
43
  subagent_type "explore" (read-only) and keep only the report.
42
44
  - A self-contained research or verification sub-task you can specify in a
43
- paragraph: delegate it and continue the main line meanwhile.
44
- - Several INDEPENDENT investigations: emit several Task calls in ONE batch.
45
+ paragraph.
46
+ - A long investigation, or several unrelated ones: call Task again for each.
47
+ EVERY call opens a FRESH isolated chat (a new subagent) — there is no
48
+ "reuse" of a previous subagent, so pass everything it needs in the prompt.
45
49
 
46
50
  Do NOT use it for:
47
51
  - work that depends on this conversation's history (the subagent cannot see
@@ -90,10 +94,17 @@ export function renderContextSection(context) {
90
94
  '\n';
91
95
  }
92
96
  if (context.memory.length) {
97
+ // Point at the DEEPEST memory file (last in the chain: the loader pushes
98
+ // globals first, then the dir chain root->workdir). Before, this used
99
+ // memory[0] — the GLOBAL ~/.zames/MEMORY.md whenever it existed — so
100
+ // project-specific facts leaked into the global file and then loaded for
101
+ // every unrelated project. Fall back to the global file only when no
102
+ // project-level MEMORY.md exists.
103
+ const memoryTarget = context.memory[context.memory.length - 1].path;
93
104
  out +=
94
105
  '\nWhen you learn a durable fact about this project or the operator preferences (build quirks, ' +
95
106
  'conventions, gotchas), append a short bullet to ' +
96
- context.memory[0].path +
107
+ memoryTarget +
97
108
  ' via Edit/Write so it survives ' +
98
109
  'into future sessions. Keep entries concise.\n';
99
110
  }
package/dist/tools.js CHANGED
@@ -279,12 +279,14 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
279
279
  name: 'Task',
280
280
  description: 'Delegate a self-contained sub-task to a SUBAGENT that works in its own ' +
281
281
  'separate chat with an isolated context, then returns ONLY a final ' +
282
- 'report. Use it to keep heavy research out of the main context. ' +
283
- 'subagent_type: "explore" (read-only: search/read, cannot modify) or ' +
284
- '"general" (full tools). The subagent does NOT see this conversation, so ' +
285
- '`prompt` must be fully self-contained: state the goal, the exact ' +
286
- 'questions, and any file paths/context it needs. Returns the subagent ' +
287
- 'report as text.',
282
+ 'report. Use it to keep heavy research out of the main context — it ' +
283
+ 'does NOT run in parallel (one browser, one send slot), so it saves ' +
284
+ 'context, not time. subagent_type: "explore" (read-only: search/read, ' +
285
+ 'cannot modify) or "general" (full tools). Each call opens a FRESH ' +
286
+ 'subagent chat; nothing is reused between calls. The subagent does NOT ' +
287
+ 'see this conversation, so `prompt` must be fully self-contained: state ' +
288
+ 'the goal, the exact questions, and any file paths/context it needs. ' +
289
+ 'Returns the subagent report as text.',
288
290
  parameters: {
289
291
  description: 'string',
290
292
  prompt: 'string',
@@ -324,6 +326,17 @@ export const MUTATING_TOOLS = new Set([
324
326
  export function filterToolsForReadOnly(tools) {
325
327
  return tools.filter((t) => !MUTATING_TOOLS.has(t.name));
326
328
  }
329
+ // A skill may declare `allowed-tools:` in its SKILL.md frontmatter (the Claude
330
+ // Code contract): the skill then runs with ONLY those tools. We keep the
331
+ // `respond` seam so the run can still finish, and match names case-insensitively
332
+ // (frontmatter is human-written). PURE; unit-tested.
333
+ export function filterToolsByAllowList(tools, allowed) {
334
+ if (!allowed || !allowed.length)
335
+ return tools;
336
+ const allow = new Set(allowed.map((n) => n.toLowerCase()));
337
+ allow.add('respond');
338
+ return tools.filter((t) => allow.has(t.name.toLowerCase()));
339
+ }
327
340
  // MCP tool names are opaque (server + '__' + tool), so the exact-name
328
341
  // MUTATING_TOOLS set cannot classify them. Instead we match the common
329
342
  // mutating VERBS in the tool name. It is a heuristic deny-list, not a proof:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.72.8",
3
+ "version": "2.72.9",
4
4
  "description": "Terminal coding agent that drives chat.deepseek.com through Playwright — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,9 @@ const FLOORS = {
32
32
  'permissions.ts': 85,
33
33
  'changelog.ts': 85,
34
34
  'attach-refs.ts': 85,
35
+ // C3: the pure /config subcommand dispatcher — silent routing regressions
36
+ // (a wrong subcommand branch) are invisible without tests.
37
+ 'config-command.ts': 85,
35
38
  // N12: web.ts holds the SSRF guard and the DuckDuckGo/HTML parsers — a
36
39
  // regression there is silent. Floors sit below the current values.
37
40
  'web.ts': 40,
@@ -160,19 +160,21 @@ if (dryRun) {
160
160
  process.exit(0)
161
161
  }
162
162
 
163
- // 5. Insert the section right after the `## [Unreleased]` heading.
163
+ // 5. Insert the version section in place of the Unreleased BODY. The
164
+ // Unreleased body is auto-synced from commits (see changelog:sync), so it is
165
+ // the same content as the new section — leaving it in place duplicated every
166
+ // entry (the real "### Fixed" block twice). Replace the whole body up to the
167
+ // next version heading with the new section.
164
168
  const changelogPath = path.join(root, 'CHANGELOG.md')
165
169
  const changelog = fs.readFileSync(changelogPath, 'utf-8')
166
170
  const anchor = '## [Unreleased]'
167
171
  const idx = changelog.indexOf(anchor)
168
172
  if (idx < 0) fail('CHANGELOG.md has no "' + anchor + '" heading')
169
173
  const afterAnchor = idx + anchor.length
170
- const updated =
171
- changelog.slice(0, afterAnchor) +
172
- NL +
173
- NL +
174
- section +
175
- changelog.slice(afterAnchor).replace(/^\n+/, NL)
174
+ const rest = changelog.slice(afterAnchor)
175
+ const nextHeading = rest.search(/\n## \[/)
176
+ const tail = nextHeading < 0 ? NL : rest.slice(nextHeading)
177
+ const updated = changelog.slice(0, afterAnchor) + NL + NL + section + tail
176
178
  fs.writeFileSync(changelogPath, updated)
177
179
 
178
180
  // 6. Bump package.json, preserving its exact formatting (single-line replace