zames_pro 2.10.7 → 2.10.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.
@@ -20,6 +20,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
20
20
  };
21
21
  };
22
22
  const safeThinking = safe(onThinking);
23
+ // Start the "working" indicator ONLY when a message is really sent (after
24
+ // the send-pause), not while the loop is still preparing the request.
25
+ // Previously safeThinking() fired before browser.ask(), so the spinner ran
26
+ // through the whole throttle pause / chat-open phase with nothing in flight.
27
+ browser.onSendStart = safeThinking;
23
28
  const safeAssistantThought = safe(onAssistantThought);
24
29
  const safeToolCall = safe(onToolCall);
25
30
  const safeToolResult = safe(onToolResult);
@@ -117,6 +122,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
117
122
  // instead get an EMPTY answer or the EXACT same answer as the previous turn
118
123
  // (a sign the new message was not sent), we nudge instead of stopping.
119
124
  let lastRaw = '';
125
+ // True when the PREVIOUS turn actually executed a tool. This is what
126
+ // makes a repeated answer "stale": the tool already ran, so running it
127
+ // again would duplicate side effects. If the previous answer looked like a
128
+ // call but was not recognized (e.g. a DSML/XML form the strict parser
129
+ // missed), the tool did NOT run and the echo is the only copy of the
130
+ // call — it must be executed, not discarded as stale.
131
+ // const ranToolLastTurn follows the previous turn's actual effect.
132
+ let lastTurnRanTool = false;
120
133
  let justRanTool = false;
121
134
  let watchdogRetries = 0;
122
135
  const MAX_WATCHDOG_RETRIES = 3;
@@ -126,11 +139,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
126
139
  let afterToolRetries = 0;
127
140
  const MAX_AFTER_TOOL_RETRIES = 6;
128
141
  for (let i = 0; i < maxIterations; i++) {
129
- safeThinking();
142
+ // NOTE: the spinner is NOT started here. browser.onSendStart fires it
143
+ // right when the message is actually typed/sent (after the send-pause),
144
+ // so no spinner runs during the pre-send phase.
130
145
  // The first message (task) is user input: no throttle.
131
146
  // Subsequent ones (tool-result and resend requests) are agent sends:
132
147
  // throttled so we don't hit the rate limit.
133
148
  const isFirst = i === 0;
149
+ // Did the PREVIOUS turn actually execute a tool? Used by the stale
150
+ // check below. We take a snapshot and immediately clear it: it will be
151
+ // set again at the end of this iteration only if a tool really runs.
152
+ const ranToolPrevTurn = lastTurnRanTool;
153
+ lastTurnRanTool = false;
134
154
  // Safety net: browser.ask() has its own timeout, but a stuck send used to
135
155
  // block the whole loop and look like a silent stop. We race it against a
136
156
  // hard deadline and treat a timeout as a nudge (re-ask), never as a
@@ -194,18 +214,31 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
194
214
  // previous answer with different markdown emphasis (`**done**` vs `done`),
195
215
  // which defeated an exact match and made the loop run the SAME tool again —
196
216
  // the "stopped after a tool call" signature with a duplicate call.
197
- const wdStale = justRanTool &&
217
+ //
218
+ // AN ECHO IS NOT ALWAYS STALE: the previous answer may have been a
219
+ // DSML/XML call that the strict parser missed and the loop never actually
220
+ // ran. In that case the echo is the ONLY copy of the call we can get,
221
+ // and discarding it as "stale" burns the watchdog budget and stalls the
222
+ // agent. So we first check whether the echo itself parses as a tool
223
+ // call (including the XML/DSML form); if it does, we do NOT treat it
224
+ // as stale — the call gets run below like any other.
225
+ // Stale means: the PREVIOUS turn actually ran a tool (lastTurnRanTool)
226
+ // and this answer is a repeat of the previous one. If the previous
227
+ // answer never ran a tool (unrecognized DSML call), the repeat is NOT
228
+ // stale — it is the only copy of the call and must be executed.
229
+ const staleCandidate = ranToolPrevTurn &&
198
230
  lastRaw.trim() !== '' &&
199
231
  (rawResponse.trim() === lastRaw.trim() ||
200
232
  normForStale(rawResponse) === normForStale(lastRaw));
233
+ const wdStale = staleCandidate;
201
234
  const wdNoCall = justRanTool &&
202
235
  !wdEmpty &&
203
236
  !wdStale &&
204
237
  parseToolCall(rawResponse) === null &&
205
238
  !responseLooksLikeToolCall(rawResponse);
206
- // A stale answer is discarded even when it parses to a valid call: it is a
207
- // duplicate of the previous turn, and re-running the tool would repeat
208
- // side effects and stall the loop.
239
+ // A stale answer is discarded only when it does NOT parse as a tool
240
+ // call (see wdStale above); an echo of a call that never ran is a real
241
+ // call and must be executed, not dropped.
209
242
  if (!isFirst && (wdEmpty || wdStale || wdNoCall) && watchdogRetries < MAX_WATCHDOG_RETRIES) {
210
243
  watchdogRetries++;
211
244
  transcript?.log('watchdog_nudge', {
@@ -464,6 +497,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
464
497
  // A tool just ran — the next answer is expected to be a fresh tool call.
465
498
  // Reset the watchdog so the next empty/repeated answer is nudged.
466
499
  justRanTool = true;
500
+ // The tool really executed on this turn. The NEXT iteration will treat
501
+ // a repeat of this answer as stale only because of this flag (see NEXT,
502
+ // not the current, justRanTool check).
503
+ lastTurnRanTool = true;
467
504
  watchdogRetries = 0;
468
505
  // A fresh tool call just ran: reset the per-tool-result nudge budget so
469
506
  // a long chain of tools is not cut off by an earlier bad turn.
package/dist/browser.js CHANGED
@@ -139,6 +139,11 @@ export class DeepSeekBrowser {
139
139
  _netSniff;
140
140
  _netSniffLimit;
141
141
  _netHookInstalled;
142
+ // Fired right when a message is actually typed/sent (AFTER the send-pause
143
+ // and attachments). Used to start the "agent is working" spinner only when
144
+ // a generation really begins, not during the pre-send phase (chat open,
145
+ // throttle wait), which used to show a spinner with no work in flight.
146
+ onSendStart;
142
147
  constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 3, stabilityDelayMs = 1000, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, } = {}) {
143
148
  this.headless = headless;
144
149
  this.debug = debug;
@@ -161,6 +166,7 @@ export class DeepSeekBrowser {
161
166
  this._netSniff = [];
162
167
  this._netSniffLimit = 5;
163
168
  this._netHookInstalled = false;
169
+ this.onSendStart = null;
164
170
  }
165
171
  async launch() {
166
172
  await killStaleChrome();
@@ -460,7 +466,11 @@ export class DeepSeekBrowser {
460
466
  throw e;
461
467
  }
462
468
  console.error(theme.warn(`⏳ DeepSeek: «слишком часто». Жду ${Math.ceil(this.rateLimitWaitMs / 60000)} мин (${rateLimitRetries}/${this.maxRateLimitRetries}) и повторю...`));
463
- await this.page.waitForTimeout(this.rateLimitWaitMs);
469
+ // Interruptible: Esc/Ctrl+C must cancel this long wait too,
470
+ // otherwise "stop" stays frozen for up to 5 minutes.
471
+ const aborted = await this._sleepInterruptible(this.rateLimitWaitMs);
472
+ if (aborted)
473
+ return '(прервано пользователем)';
464
474
  continue;
465
475
  }
466
476
  console.error(`\n⚠ ask() попытка ${attempt}/${this.askRetries} провалилась: ${e.message}`);
@@ -605,6 +615,12 @@ export class DeepSeekBrowser {
605
615
  // Pause between sends. Applied ONLY to agent messages
606
616
  // (tool-result, system-prompt) so we don't hit the rate limit.
607
617
  // User input is sent without delay.
618
+ //
619
+ // The wait is INTERRUPTIBLE: Esc/Ctrl+C sets _abort, and we check it every
620
+ // 100ms instead of one long page.waitForTimeout(gap). Before, Esc pressed
621
+ // during this pause did nothing to the pause itself — the send was still
622
+ // delayed by the remaining seconds, and the stop request only took effect
623
+ // after the pause. This is the main "Esc does not cancel the pause" bug.
608
624
  async _waitForSendSlot(agent) {
609
625
  if (!agent)
610
626
  return;
@@ -614,7 +630,21 @@ export class DeepSeekBrowser {
614
630
  if (gap <= 0)
615
631
  return;
616
632
  console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
617
- await this.page.waitForTimeout(gap);
633
+ await this._sleepInterruptible(gap);
634
+ }
635
+ // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
636
+ // Returns true if the sleep was cut short by _abort.
637
+ async _sleepInterruptible(ms) {
638
+ const step = 100;
639
+ let left = ms;
640
+ while (left > 0) {
641
+ if (this._abort)
642
+ return true;
643
+ const chunk = Math.min(step, left);
644
+ await this.page.waitForTimeout(chunk);
645
+ left -= chunk;
646
+ }
647
+ return this._abort;
618
648
  }
619
649
  // DEBUG: append ask() phases to ~/.zames/ask-debug.log so a stall can be
620
650
  // diagnosed from the field (what the DOM/network looked like at each step).
@@ -630,7 +660,12 @@ export class DeepSeekBrowser {
630
660
  }
631
661
  async _askOnce(prompt, { timeout, agent, attachments = [], }) {
632
662
  // We reset the abort flag ONLY at the very start of the send.
663
+ // _stopped (Esc for the WHOLE batch) is NOT reset here: it may have been
664
+ // set while a previous send was waiting in the pause. If it is set, the
665
+ // user asked to stop and we must not start a new generation at all.
633
666
  this._abort = false;
667
+ if (this._stopped)
668
+ return '(прервано пользователем)';
634
669
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
635
670
  if (!input) {
636
671
  throw new Error('Не найдено поле ввода. Запустите /debug-dom и поправьте INPUT_SELECTORS.');
@@ -638,6 +673,19 @@ export class DeepSeekBrowser {
638
673
  const beforeText = await this._readLastAnswerTextClean().catch(() => '');
639
674
  this._askDebug('SEND agent=' + agent + ' len=' + prompt.length + ' beforeLen=' + beforeText.length + ' beforeHead=' + JSON.stringify(beforeText.slice(0, 60)));
640
675
  await this._waitForSendSlot(agent);
676
+ // Esc/Ctrl+C pressed during the pause — do not send anything.
677
+ if (this._abort)
678
+ return '(прервано пользователем)';
679
+ // The pause is over and we are really about to type/send: only NOW start
680
+ // the "working" indicator. The spinner used to be started by the caller
681
+ // BEFORE ask(), so it ran for the whole throttle pause and chat-opening
682
+ // phase with no generation in flight.
683
+ if (this.onSendStart) {
684
+ try {
685
+ this.onSendStart();
686
+ }
687
+ catch { }
688
+ }
641
689
  this._netCapture = '';
642
690
  this._netCaptureAt = 0;
643
691
  // Attach files/images FIRST (before the text): the DeepSeek upload widget
package/dist/i18n.js CHANGED
@@ -128,6 +128,7 @@ const CATALOG = {
128
128
  'msg.new_chat': { ru: 'Создаю новый чат...', en: 'Creating a new chat...' },
129
129
  'msg.new_chat_ok': { ru: 'Новый чат.', en: 'New chat.' },
130
130
  'msg.resuming': { ru: 'Восстанавливаю сессию {id}...', en: 'Restoring session {id}...' },
131
+ 'msg.input_locked': { ru: 'Идёт операция, ввод временно заблокирован…', en: 'Operation in progress, input is temporarily locked…' },
131
132
  'msg.opening_chat': { ru: 'Открываю чат {id}...', en: 'Opening chat {id}...' },
132
133
  'msg.chat_opened': { ru: 'Чат открыт.', en: 'Chat opened.' },
133
134
  'msg.interactive': { ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.', en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.' },
package/dist/index.js CHANGED
@@ -714,9 +714,15 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
714
714
  };
715
715
  // Execute the task, then everything the user managed to type while it
716
716
  // ran. The queue may be replenished right during draining.
717
+ //
718
+ // NOTE: we do NOT call ui.thinking() here. runAgentLoop() fires
719
+ // onThinking() right before the actual browser send (after the send
720
+ // pause, system-prompt, etc.), so the spinner only appears when a
721
+ // generation really starts. Calling it here made the spinner run for
722
+ // the whole pre-send phase (chat creation, throttle wait) with no
723
+ // generation in flight.
717
724
  while (true) {
718
- ui.thinking();
719
- await mod.runAgentLoop({
725
+ const outcome = await mod.runAgentLoop({
720
726
  browser,
721
727
  tools,
722
728
  task: next.text,
@@ -735,6 +741,16 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
735
741
  debugLog: debug,
736
742
  locale: currentLocale,
737
743
  });
744
+ // The loop may end WITHOUT a model answer: an exhausted iteration
745
+ // limit or an ask() watchdog (the model stopped responding). In that
746
+ // case no assistant message was shown, and the operator saw the run
747
+ // just "stop" after a tool call with no explanation. Surface it.
748
+ if (outcome &&
749
+ (outcome.startsWith('Достигнут лимит итераций') ||
750
+ outcome.startsWith('ask() watchdog'))) {
751
+ ui.warning(outcome);
752
+ transcript?.log('agent_no_answer', { outcome });
753
+ }
738
754
  // Aborted (Esc/Ctrl+C) — we don't start the next tasks from the queue
739
755
  // and clear it, so "stop" really stops everything.
740
756
  if (browser._stopped) {
@@ -770,6 +786,9 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
770
786
  finally {
771
787
  stopWatching();
772
788
  ui.stop();
789
+ // Detach the send hook so a later browser.ask() outside this task cannot
790
+ // start a stale spinner.
791
+ browser.onSendStart = null;
773
792
  }
774
793
  }
775
794
  // ---------- main ----------
@@ -1339,6 +1358,8 @@ async function main() {
1339
1358
  }
1340
1359
  if (['/new', '/clear', 'new'].includes(lower)) {
1341
1360
  console.log(theme.system(t('msg.new_chat')));
1361
+ if (editor)
1362
+ editor.lock(t('msg.input_locked'));
1342
1363
  try {
1343
1364
  await browser.newChat();
1344
1365
  freshChatNext = false;
@@ -1351,6 +1372,10 @@ async function main() {
1351
1372
  catch (e) {
1352
1373
  console.error(theme.error(t('msg.new_chat_error', { v: e.message })), e.message);
1353
1374
  }
1375
+ finally {
1376
+ if (editor)
1377
+ editor.unlock();
1378
+ }
1354
1379
  continue;
1355
1380
  }
1356
1381
  // ---------- Self-review ----------
@@ -1360,6 +1385,8 @@ async function main() {
1360
1385
  const originalWorkdir = reviewMode
1361
1386
  ? reviewMode.originalWorkdir
1362
1387
  : currentWorkdir;
1388
+ if (editor)
1389
+ editor.lock(t('msg.input_locked'));
1363
1390
  try {
1364
1391
  const result = await mod.selfReview({
1365
1392
  browser,
@@ -1388,6 +1415,10 @@ async function main() {
1388
1415
  if (debug)
1389
1416
  console.error(e.stack);
1390
1417
  }
1418
+ finally {
1419
+ if (editor)
1420
+ editor.unlock();
1421
+ }
1391
1422
  continue;
1392
1423
  }
1393
1424
  if (lower === '/self-fix' || lower.startsWith('/self-fix ')) {
@@ -1513,6 +1544,8 @@ async function main() {
1513
1544
  // ---------- Regular commands ----------
1514
1545
  if (lower === '/chats') {
1515
1546
  const spin = editor || mod.createSpinner(currentLocale);
1547
+ if (editor)
1548
+ editor.lock(t('msg.input_locked'));
1516
1549
  spin.thinking();
1517
1550
  try {
1518
1551
  lastChats = await browser.listChats(30);
@@ -1533,6 +1566,10 @@ async function main() {
1533
1566
  spin.stop();
1534
1567
  console.error(theme.error(t('chats.fetch_error')), e.message);
1535
1568
  }
1569
+ finally {
1570
+ if (editor)
1571
+ editor.unlock();
1572
+ }
1536
1573
  continue;
1537
1574
  }
1538
1575
  if (lower === '/resume' || lower.startsWith('/resume ')) {
@@ -1556,6 +1593,8 @@ async function main() {
1556
1593
  continue;
1557
1594
  }
1558
1595
  console.log(theme.system(t('chats.opening', { v: pick.title })));
1596
+ if (editor)
1597
+ editor.lock(t('msg.input_locked'));
1559
1598
  try {
1560
1599
  await browser.openChat(pick.id);
1561
1600
  currentChatId = pick.id;
@@ -1571,6 +1610,10 @@ async function main() {
1571
1610
  catch (e) {
1572
1611
  console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1573
1612
  }
1613
+ finally {
1614
+ if (editor)
1615
+ editor.unlock();
1616
+ }
1574
1617
  continue;
1575
1618
  }
1576
1619
  if (lower === '/chat') {
@@ -1608,6 +1651,8 @@ async function main() {
1608
1651
  console.error(theme.error(t('sessions.resume_id_usage')));
1609
1652
  continue;
1610
1653
  }
1654
+ if (editor)
1655
+ editor.lock(t('msg.input_locked'));
1611
1656
  try {
1612
1657
  console.log(theme.system(t('msg.opening_chat', { id })));
1613
1658
  await browser.openChat(id);
@@ -1622,6 +1667,10 @@ async function main() {
1622
1667
  catch (e) {
1623
1668
  console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1624
1669
  }
1670
+ finally {
1671
+ if (editor)
1672
+ editor.unlock();
1673
+ }
1625
1674
  continue;
1626
1675
  }
1627
1676
  if (lower === '/pwd') {
package/dist/input.js CHANGED
@@ -207,6 +207,11 @@ export class LineEditor {
207
207
  // Clipboard callback: tries to read an image from the OS clipboard when the
208
208
  // terminal itself sends no usable data (Ctrl+V, right-click, empty paste).
209
209
  onClipboard;
210
+ // When locked, the editor ignores text input and Enter (submit). Used while
211
+ // a long operation runs (chat resume/open, /new, self-review): otherwise the
212
+ // user could type and send a message mid-operation, and it would be queued
213
+ // and sent right after the operation, breaking the restored session.
214
+ locked;
210
215
  constructor({ prompt = '> ', commands = [] } = {}) {
211
216
  this.promptStr = prompt;
212
217
  this.buf = '';
@@ -236,6 +241,7 @@ export class LineEditor {
236
241
  this._tmpDir = '';
237
242
  this.onAttach = null;
238
243
  this.onClipboard = null;
244
+ this.locked = false;
239
245
  }
240
246
  // Read the OS clipboard for an image and insert its marker. Used when the
241
247
  // terminal sends no usable paste data (Ctrl+V / right-click / empty paste).
@@ -333,6 +339,19 @@ export class LineEditor {
333
339
  this._writeBlock();
334
340
  this._render();
335
341
  }
342
+ // Lock input while a long operation runs (chat resume/open, /new,
343
+ // self-review). The line stays visible but text input and Enter are ignored,
344
+ // so a message typed mid-operation is not queued and sent afterwards,
345
+ // breaking the restored session. Ctrl+C/Ctrl+D still work so the user can
346
+ // abort.
347
+ lock(status) {
348
+ this.locked = true;
349
+ if (status)
350
+ this.setStatus(theme.dim(status));
351
+ }
352
+ unlock() {
353
+ this.locked = false;
354
+ }
336
355
  setPrompt(str) {
337
356
  this.promptStr = str;
338
357
  this._render();
@@ -481,6 +500,10 @@ export class LineEditor {
481
500
  }
482
501
  // ---------- input ----------
483
502
  _submit() {
503
+ if (this.locked) {
504
+ this._render();
505
+ return;
506
+ }
484
507
  const display = this.buf;
485
508
  if (!display.trim()) {
486
509
  this._render();
@@ -716,6 +739,25 @@ export class LineEditor {
716
739
  }
717
740
  _handle(data) {
718
741
  let s = data.toString('utf-8');
742
+ // While locked (a long operation is running), swallow all input except
743
+ // Ctrl+C / Ctrl+D — so the user can still abort, but cannot type a message
744
+ // that would be queued and sent after the operation.
745
+ if (this.locked) {
746
+ if (s.length === 1) {
747
+ const c = s.charCodeAt(0);
748
+ if (c === 3) {
749
+ if (this.onCtrlC)
750
+ this.onCtrlC();
751
+ return;
752
+ }
753
+ if (c === 4) {
754
+ if (this.onCtrlC)
755
+ this.onCtrlC();
756
+ return;
757
+ }
758
+ }
759
+ return;
760
+ }
719
761
  if (!this._inPaste && s === ESC) {
720
762
  if (this.onEscape)
721
763
  this.onEscape();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.10.7",
3
+ "version": "2.10.9",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {