zames_pro 2.32.0 → 2.34.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.
@@ -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 = () => { }, onSendPause = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, }) {
7
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, }) {
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
@@ -30,6 +30,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
30
30
  // seconds, the UI shows an animated status instead of a frozen line.
31
31
  const safeSendPause = safe(onSendPause);
32
32
  browser.onSendPause = safeSendPause;
33
+ // Coarse lifecycle of the send (generating/paused/settled) for the status
34
+ // line. Same best-effort wiring as the other hooks.
35
+ browser.onSendState = safe(onSendState);
33
36
  // Service notices (rate limit, server busy, resend) must reach the OPERATOR.
34
37
  // While a LineEditor is active a raw console.error is overwritten by the
35
38
  // editor's repaint, so the operator only saw the spinner. `onNotice` prints
package/dist/browser.js CHANGED
@@ -400,6 +400,12 @@ export class DeepSeekBrowser {
400
400
  // remaining seconds. Lets the UI animate the pause status instead of
401
401
  // printing a static line (the dots used to be frozen during the pause).
402
402
  onSendPause;
403
+ // Coarse lifecycle of one send: 'generating' right before the message is
404
+ // typed, 'paused' while waiting out a throttle/Continue gap, 'settled'
405
+ // once the answer stopped changing. Lets the UI show an explicit phase
406
+ // instead of only an on/off spinner (a long reasoning turn and a rate-limit
407
+ // wait looked identical). Firing this hook does not alter any send timing.
408
+ onSendState;
403
409
  // A service notice for the OPERATOR (rate limit, server busy, resend, …).
404
410
  // IMPORTANT: these must NOT go through `console.error` directly — while a
405
411
  // LineEditor is active it repaints its own status line and the raw stderr
@@ -458,6 +464,7 @@ export class DeepSeekBrowser {
458
464
  this._netHookInstalled = false;
459
465
  this.onSendStart = null;
460
466
  this.onSendPause = null;
467
+ this.onSendState = null;
461
468
  this.onNotice = null;
462
469
  // An explicit UA (config/options) is respected as-is. Otherwise it stays
463
470
  // empty here and is derived from the real engine UA after launch — see
@@ -482,6 +489,16 @@ export class DeepSeekBrowser {
482
489
  }
483
490
  console.error(text);
484
491
  }
492
+ // Report the coarse lifecycle of one send to the UI hook (if wired).
493
+ // Best-effort: a broken UI callback must never break the send loop.
494
+ _emitSendState(state) {
495
+ if (this.onSendState) {
496
+ try {
497
+ this.onSendState(state);
498
+ }
499
+ catch { }
500
+ }
501
+ }
485
502
  async launch() {
486
503
  // NOTE: we deliberately do NOT kill stray chrome processes here. Per the
487
504
  // project rules, an agent must never kill a browser it did not start —
@@ -1606,7 +1623,18 @@ export class DeepSeekBrowser {
1606
1623
  // at the start burns the whole askRetries budget and ask() gives up early.
1607
1624
  while (attempt < this.askRetries) {
1608
1625
  try {
1609
- return await this._askOnce(prompt, { timeout, agent, attachments });
1626
+ const answer = await this._askOnce(prompt, {
1627
+ timeout,
1628
+ agent,
1629
+ attachments,
1630
+ });
1631
+ // The answer has settled (the finish loop returned it): mark the
1632
+ // lifecycle state so the status line shows "settled" instead of
1633
+ // still looking like generation.
1634
+ if (answer !== '(прервано пользователем)') {
1635
+ this._emitSendState('settled');
1636
+ }
1637
+ return answer;
1610
1638
  }
1611
1639
  catch (e) {
1612
1640
  lastErr = e;
@@ -1892,6 +1920,7 @@ export class DeepSeekBrowser {
1892
1920
  catch { }
1893
1921
  }
1894
1922
  };
1923
+ this._emitSendState('paused');
1895
1924
  tick(totalSec * 1000);
1896
1925
  return await this._sleepInterruptible(totalSec * 1000, tick);
1897
1926
  }
@@ -1937,6 +1966,7 @@ export class DeepSeekBrowser {
1937
1966
  }
1938
1967
  };
1939
1968
  report(gap);
1969
+ this._emitSendState('paused');
1940
1970
  await this._sleepInterruptible(gap, report);
1941
1971
  }
1942
1972
  // Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
@@ -2022,6 +2052,7 @@ export class DeepSeekBrowser {
2022
2052
  }
2023
2053
  catch { }
2024
2054
  }
2055
+ this._emitSendState('generating');
2025
2056
  this._netCapture = '';
2026
2057
  this._netCaptureAt = 0;
2027
2058
  this._netNoAnswer = false;
package/dist/commands.js CHANGED
@@ -71,17 +71,20 @@ export function parseTranscript(body) {
71
71
  }
72
72
  return out;
73
73
  }
74
- export function formatDuration(ms) {
74
+ export const EN_DURATION_UNITS = { h: 'h', m: 'm', s: 's' };
75
+ // Human-readable duration: "8s", "5m 12s", "1h 01m 01s". Seconds are padded
76
+ // only when a larger unit is present, so a lone "45s" is not "45s" padded.
77
+ export function formatDuration(ms, units = EN_DURATION_UNITS) {
75
78
  const total = Math.max(0, Math.round(ms / 1000));
76
79
  const h = Math.floor(total / 3600);
77
80
  const m = Math.floor((total % 3600) / 60);
78
81
  const s = total % 60;
79
82
  const pad = (n) => String(n).padStart(2, '0');
80
83
  if (h > 0)
81
- return h + 'h ' + pad(m) + 'm ' + pad(s) + 's';
84
+ return h + units.h + ' ' + pad(m) + units.m + ' ' + pad(s) + units.s;
82
85
  if (m > 0)
83
- return m + 'm ' + pad(s) + 's';
84
- return s + 's';
86
+ return m + units.m + ' ' + pad(s) + units.s;
87
+ return s + units.s;
85
88
  }
86
89
  export function renderCost(stats, transcriptFile, tokenUsage = null) {
87
90
  const lines = [];
@@ -430,6 +433,86 @@ export function isSlashCommand(text) {
430
433
  .trim()
431
434
  .startsWith('/');
432
435
  }
436
+ export function parseLiveToggle(text) {
437
+ const m = /^\/(thinking|web|websearch|search)(?:\s+(\S+))?\s*$/i.exec(String(text ?? '').trim());
438
+ if (!m)
439
+ return null;
440
+ const name = m[1].toLowerCase();
441
+ const kind = name === 'thinking' ? 'thinking' : 'search';
442
+ const arg = (m[2] || '').toLowerCase();
443
+ let mode = 'toggle';
444
+ if (arg === 'on' || arg === '1' || arg === 'true' || arg === 'вкл')
445
+ mode = 'on';
446
+ else if (arg === 'off' || arg === '0' || arg === 'false' || arg === 'выкл')
447
+ mode = 'off';
448
+ else if (arg !== '')
449
+ return null;
450
+ return { kind, mode };
451
+ }
452
+ // Prepend a long-lived session goal to a task message, so the model keeps the
453
+ // big picture even when the goal was set many turns ago. The task is appended
454
+ // AFTER the goal; when there is no goal the task is returned unchanged. The
455
+ // text is agent-facing (English), like the system prompt.
456
+ export function withGoal(goal, task) {
457
+ const g = String(goal ?? '').trim();
458
+ if (!g)
459
+ return task;
460
+ const NL = String.fromCharCode(10);
461
+ return ('Session goal (long-lived — keep it in mind for this and every following task):' +
462
+ NL +
463
+ g +
464
+ NL +
465
+ NL +
466
+ 'Current task:' +
467
+ NL +
468
+ task);
469
+ }
470
+ // Parse a `/goal` invocation. Subcommands: 'set' (with text), 'show', 'clear'.
471
+ // Returns null when the text is not a /goal command. Kept pure so the main loop
472
+ // and any future interception share one parser.
473
+ export function parseGoalCommand(text) {
474
+ const t = String(text ?? '').trim();
475
+ const m = /^\/goal(?:\s+([\s\S]*))?$/.exec(t);
476
+ if (!m)
477
+ return null;
478
+ const rest = (m[1] ?? '').trim();
479
+ if (rest === '')
480
+ return { sub: 'show' };
481
+ if (rest.toLowerCase() === 'clear' || rest.toLowerCase() === 'off')
482
+ return { sub: 'clear' };
483
+ return { sub: 'set', goal: rest };
484
+ }
485
+ // null when the text is not a /queue command at all. This runs WHILE the agent
486
+ // is busy (intercepted in the input handler), because the main command loop is
487
+ // blocked on runTask() and a queued /queue would only run after the task —
488
+ // when inspecting/clearing the queue is pointless.
489
+ export function parseQueueCommand(text) {
490
+ const t = String(text ?? '').trim();
491
+ const m = /^\/queue(?:\s+(\S+))?\s*$/.exec(t);
492
+ if (!m)
493
+ return null;
494
+ const arg = (m[1] || '').toLowerCase();
495
+ if (arg === '' || arg === 'list' || arg === 'ls')
496
+ return { sub: 'list' };
497
+ if (arg === 'clear' || arg === 'c' || arg === 'clean')
498
+ return { sub: 'clear' };
499
+ // An unrecognized subcommand falls through to the normal main-loop handler,
500
+ // which prints the usage hint.
501
+ return null;
502
+ }
503
+ // Render the queued messages for the operator (one line per message, text
504
+ // truncated). Pure, so the /queue interception and the main-loop handler share
505
+ // the EXACT same output instead of duplicating the formatting.
506
+ export function formatQueueList(msgs) {
507
+ return (msgs || []).map((m, i) => {
508
+ const one = String(m.text || '')
509
+ .replace(/\s+/g, ' ')
510
+ .trim();
511
+ const shown = one.length > 80 ? one.slice(0, 80) + ' …' : one;
512
+ const att = m.attachments?.length ? ' [+' + m.attachments.length + ']' : '';
513
+ return ' ' + (i + 1) + '. ' + shown + att;
514
+ });
515
+ }
433
516
  function queuedIsImage(a) {
434
517
  return (String(a.mime || '').startsWith('image/') ||
435
518
  isImageName(String(a.name || '')));
package/dist/i18n.js CHANGED
@@ -205,6 +205,78 @@ const CATALOG = {
205
205
  ru: '/queue [clear] показать/очистить очередь сообщений',
206
206
  en: '/queue [clear] list/clear the pending message queue',
207
207
  },
208
+ 'help.cmd.thinking': {
209
+ ru: '/thinking [on|off] включить/выключить режим размышления (можно во время работы)',
210
+ en: '/thinking [on|off] toggle Deep thinking (works while the agent is busy)',
211
+ },
212
+ 'help.cmd.web': {
213
+ ru: '/web [on|off] включить/выключить поиск в интернете (можно во время работы)',
214
+ en: '/web [on|off] toggle Smart search (works while the agent is busy)',
215
+ },
216
+ 'help.cmd.goal': {
217
+ ru: '/goal [текст|clear] задать долгоживущую цель сессии',
218
+ en: '/goal [text|clear] set a long-lived session goal',
219
+ },
220
+ 'help.cmd.loop': {
221
+ ru: '/loop <интервал> <задача> периодически повторять задачу (напр. 10m)',
222
+ en: '/loop <interval> <task> repeat a task periodically (e.g. 10m)',
223
+ },
224
+ 'help.cmd.cron': {
225
+ ru: '/cron <выражение> <задача> запускать по расписанию (5 полей)',
226
+ en: '/cron <expr> <task> schedule a task (5-field cron)',
227
+ },
228
+ 'help.cmd.jobs': {
229
+ ru: '/jobs [rm <id>|clear] список/удаление запланированных задач',
230
+ en: '/jobs [rm <id>|clear] list/remove scheduled tasks',
231
+ },
232
+ 'sched.loop_usage': {
233
+ ru: 'Использование: /loop <интервал> <задача>, например /loop 10m проверь тесты',
234
+ en: 'Usage: /loop <interval> <task>, e.g. /loop 10m check the tests',
235
+ },
236
+ 'sched.cron_usage': {
237
+ ru: 'Использование: /cron "<мин> <час> <день> <мес> <день недели>" <задача>',
238
+ en: 'Usage: /cron "<min> <hour> <dom> <month> <dow>" <task>',
239
+ },
240
+ 'sched.added': {
241
+ ru: '⏰ Задача #{id} добавлена ({when}): {task}',
242
+ en: '⏰ Job #{id} added ({when}): {task}',
243
+ },
244
+ 'sched.title': { ru: 'Запланированные задачи:', en: 'Scheduled jobs:' },
245
+ 'sched.none': { ru: 'Запланированных задач нет.', en: 'No scheduled jobs.' },
246
+ 'sched.fired': {
247
+ ru: '⏰ Задача #{id} сработала: {task}',
248
+ en: '⏰ Job #{id} fired: {task}',
249
+ },
250
+ 'sched.cleared': { ru: 'Задачи очищены: {n}', en: 'Jobs cleared: {n}' },
251
+ 'sched.removed': { ru: 'Задача #{id} удалена.', en: 'Job #{id} removed.' },
252
+ 'sched.rm_usage': {
253
+ ru: 'Использование: /jobs rm <id>',
254
+ en: 'Usage: /jobs rm <id>',
255
+ },
256
+ 'sched.remove_hint': {
257
+ ru: 'Удалить: /jobs rm <id> · очистить всё: /jobs clear',
258
+ en: 'Remove: /jobs rm <id> · clear all: /jobs clear',
259
+ },
260
+ 'goal.set': { ru: '🎯 Цель сессии: {goal}', en: '🎯 Session goal: {goal}' },
261
+ 'goal.loaded': {
262
+ ru: '🎯 Восстановлена цель сессии: {goal}',
263
+ en: '🎯 Restored session goal: {goal}',
264
+ },
265
+ 'goal.current': {
266
+ ru: '🎯 Цель сессии: {goal}',
267
+ en: '🎯 Session goal: {goal}',
268
+ },
269
+ 'goal.none': {
270
+ ru: 'Цель сессии не задана. /goal <текст>, /goal clear.',
271
+ en: 'No session goal. /goal <text>, /goal clear.',
272
+ },
273
+ 'goal.cleared': {
274
+ ru: '🎯 Цель сессии очищена.',
275
+ en: '🎯 Session goal cleared.',
276
+ },
277
+ 'toggle.thinking': { ru: 'Размышление', en: 'Deep thinking' },
278
+ 'toggle.search': { ru: 'Поиск в интернете', en: 'Smart search' },
279
+ 'toggle.set': { ru: '{name}: {state}', en: '{name}: {state}' },
208
280
  'compact.auto_trigger': {
209
281
  ru: '🗜️ Контекст заполнен на {pct}% ({tokens} токенов) — сжимаю историю и открываю новый чат...',
210
282
  en: '🗜️ Context at {pct}% ({tokens} tokens) — compacting the history and opening a fresh chat...',
@@ -374,6 +446,10 @@ const CATALOG = {
374
446
  ru: '⚙ выполняю {name}',
375
447
  en: '⚙ running {name}',
376
448
  },
449
+ // Send/generation lifecycle indicator in the status line. Unlike the
450
+ // animated thinking phrase, it says explicitly that a generation is in
451
+ // flight. 'paused'/'settled' have no label (see LineEditor._stateLabel).
452
+ 'state.generating': { ru: '▶ генерация', en: '▶ generating' },
377
453
  'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
378
454
  'editor.answer': { ru: '● Ответ', en: '● Answer' },
379
455
  // ---------- status ----------
@@ -462,6 +538,13 @@ const CATALOG = {
462
538
  en: '⏹ Ctrl+C — aborting generation...',
463
539
  },
464
540
  'msg.queued': { ru: '📨 В очередь ({n}): ', en: '📨 Queued ({n}): ' },
541
+ // Shown ONCE per session, the first time a message is queued while the
542
+ // agent is working. It explains what happens (sent after the current task)
543
+ // and how to inspect/clear the queue, so the operator knows nothing is lost.
544
+ 'msg.queued_hint': {
545
+ ru: 'Сообщение в очереди — уйдёт после текущей задачи; /queue покажет и очистит.',
546
+ en: 'Message queued — it will be sent after the current task; /queue lists or clears it.',
547
+ },
465
548
  'msg.attached_image': {
466
549
  ru: '🖼 Вложено изображение {marker} ({size}) — {path}',
467
550
  en: '🖼 Attached image {marker} ({size}) — {path}',
@@ -482,10 +565,15 @@ const CATALOG = {
482
565
  ru: ' Похоже, вы в контейнере/Dev Container: клипборд Windows отсюда недоступен. Вставьте путь к файлу (например tmp/pic.png) — он приложится к сообщению.',
483
566
  en: ' Looks like a container/Dev Container: the Windows clipboard is not reachable from here. Paste a path to a file (e.g. tmp/pic.png) — it will be attached to the message.',
484
567
  },
485
- 'msg.task_summary': {
486
- ru: '· задача: {sec}с · инструментов: {tools}',
487
- en: '· task: {sec}s · tools: {tools}',
488
- },
568
+ // Per-task summary parts (joined with "·" by index.ts). Split into separate
569
+ // keys so the tokens part can be omitted when the counter is unknown.
570
+ 'task_sum.dur': { ru: 'длительность: {dur}', en: 'duration: {dur}' },
571
+ 'task_sum.tools': { ru: 'инструментов: {n}', en: 'tools: {n}' },
572
+ 'task_sum.tokens': { ru: 'токенов: {n}', en: 'tokens: {n}' },
573
+ // Unit labels for the localized task duration (see formatDuration).
574
+ 'dur.h': { ru: 'ч', en: 'h' },
575
+ 'dur.m': { ru: 'м', en: 'm' },
576
+ 'dur.s': { ru: 'с', en: 's' },
489
577
  'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
490
578
  'msg.from_queue_batch': {
491
579
  ru: '▶ Из очереди ({n} сообщ.): ',
@@ -498,7 +586,6 @@ const CATALOG = {
498
586
  'msg.queue_title': { ru: 'Очередь сообщений:', en: 'Pending messages:' },
499
587
  'msg.queue_empty': { ru: 'Очередь пуста.', en: 'The queue is empty.' },
500
588
  'msg.queue_cleared': { ru: 'Очередь очищена.', en: 'Queue cleared.' },
501
- 'msg.queue_item': { ru: '{n}. {text}', en: '{n}. {text}' },
502
589
  'msg.queue_usage': {
503
590
  ru: 'Использование: /queue [clear]',
504
591
  en: 'Usage: /queue [clear]',
@@ -876,6 +963,10 @@ const CATALOG = {
876
963
  },
877
964
  'mcp.status_error': { ru: '(ошибка: {v})', en: '(error: {v})' },
878
965
  'status.mcp': { ru: 'MCP-инструменты: {v}', en: 'MCP tools: {v}' },
966
+ 'status.toggles': {
967
+ ru: 'Размышление: {think} · Поиск: {search}',
968
+ en: 'Deep thinking: {think} · Search: {search}',
969
+ },
879
970
  'status.tokens': {
880
971
  ru: 'Контекст (токенов): {v}',
881
972
  en: 'Context (tokens): {v}',
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { DeepSeekBrowser } from './browser.js';
8
8
  import { createTools } from './tools.js';
9
9
  import { runAgentLoop } from './agent-loop.js';
10
10
  import { createSpinner } from './spinner.js';
11
- import { LineEditor, expandPastes, pasteReplacement, } from './input.js';
11
+ import { LineEditor, expandPastes, pasteReplacement, formatCompactTokens, } from './input.js';
12
12
  import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, } from './attachments.js';
13
13
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
14
14
  import { runConfigMenu } from './config-menu.js';
@@ -16,8 +16,9 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
16
16
  import { Transcript } from './transcript.js';
17
17
  import { UndoStore } from './undo.js';
18
18
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
19
- import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, isSlashCommand, ctrlCEscalation, } from './commands.js';
19
+ import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, formatDuration, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
20
20
  import { performCompact } from './compact.js';
21
+ import { Scheduler, parseInterval, formatInterval, parseCron, } from './scheduler.js';
21
22
  import { renderMarkdown } from './markdown.js';
22
23
  import { closeWeb } from './web.js';
23
24
  import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
@@ -108,6 +109,36 @@ function loadLastChat(workdir = '') {
108
109
  const s = loadLastSession(workdir);
109
110
  return s ? s.id : null;
110
111
  }
112
+ // Persistent per-project session goal (set by /goal). Kept OUT of the session
113
+ // JSON (a session == a DeepSeek chat) because the goal is a property of the
114
+ // PROJECT, not of one chat: it must survive /new and /compact. Best-effort —
115
+ // a missing/unreadable file simply means "no goal".
116
+ function goalFilePath(workdir) {
117
+ return path.join(workdir, '.zames-goal');
118
+ }
119
+ async function loadGoal(workdir) {
120
+ try {
121
+ const txt = await fs.readFile(goalFilePath(workdir), 'utf-8');
122
+ const trimmed = txt.trim();
123
+ return trimmed || null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ async function saveGoal(workdir, goal) {
130
+ try {
131
+ if (goal && goal.trim()) {
132
+ await fs.writeFile(goalFilePath(workdir), goal.trim() + '\n', 'utf-8');
133
+ }
134
+ else {
135
+ await fs.rm(goalFilePath(workdir), { force: true });
136
+ }
137
+ }
138
+ catch {
139
+ // best-effort: a failed write must not break the command
140
+ }
141
+ }
111
142
  // ---------- hot reload ----------
112
143
  // We keep references to the logic modules in the mod object. The /reload
113
144
  // command re-reads them via a dynamic import with a timestamp query — Node
@@ -287,6 +318,12 @@ ${theme.bold(t('help.commands'))}
287
318
  ${t('help.cmd.permissions')}
288
319
  ${t('help.cmd.add_dir')}
289
320
  ${t('help.cmd.review')}
321
+ ${t('help.cmd.goal')}
322
+ ${t('help.cmd.loop')}
323
+ ${t('help.cmd.cron')}
324
+ ${t('help.cmd.jobs')}
325
+ ${t('help.cmd.thinking')}
326
+ ${t('help.cmd.web')}
290
327
  ${t('help.cmd.compact')}
291
328
  ${t('help.cmd.queue')}
292
329
  ${t('help.cmd.config')}
@@ -344,6 +381,12 @@ const SLASH_COMMANDS = [
344
381
  { name: '/permissions', key: 'help.cmd.permissions' },
345
382
  { name: '/add-dir', key: 'help.cmd.add_dir' },
346
383
  { name: '/review', key: 'help.cmd.review' },
384
+ { name: '/goal', key: 'help.cmd.goal' },
385
+ { name: '/loop', key: 'help.cmd.loop' },
386
+ { name: '/cron', key: 'help.cmd.cron' },
387
+ { name: '/jobs', key: 'help.cmd.jobs' },
388
+ { name: '/thinking', key: 'help.cmd.thinking' },
389
+ { name: '/web', key: 'help.cmd.web' },
347
390
  { name: '/compact', key: 'help.cmd.compact' },
348
391
  { name: '/queue', key: 'help.cmd.queue' },
349
392
  { name: '/config', key: 'help.cmd.config' },
@@ -935,7 +978,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
935
978
  }
936
979
  // ---------- task runner ----------
937
980
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
938
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, } = opts;
981
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, } = opts;
939
982
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
940
983
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
941
984
  // mode (pipes) — a regular spinner + watchInput.
@@ -984,15 +1027,20 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
984
1027
  // the whole pre-send phase (chat creation, throttle wait) with no
985
1028
  // generation in flight.
986
1029
  while (true) {
987
- // T7: per-task summary (duration + number of tool calls). Counts tools
988
- // via the UI callback and reports ONE dim line after the task, so the
989
- // operator sees how long it took and how much work happened.
1030
+ // T7: per-task summary (duration + number of tool calls + tokens).
1031
+ // Counts tools via the UI callback and reports ONE line after the task,
1032
+ // so the operator sees how long it took and how much work happened.
990
1033
  const taskStart = Date.now();
1034
+ // Token delta for THIS task: accumulated_token_usage is cumulative for
1035
+ // the whole chat, so the per-task spend is after - before. Null when the
1036
+ // counter is unknown (fresh chat / no answer yet); the token part is then
1037
+ // omitted from the summary.
1038
+ const tokensBefore = getTokenUsage ? getTokenUsage() : null;
991
1039
  let taskTools = 0;
992
1040
  const outcome = await mod.runAgentLoop({
993
1041
  browser,
994
1042
  tools,
995
- task: next.text,
1043
+ task: withGoal(goal, next.text),
996
1044
  workdir,
997
1045
  maxIterations: maxIter,
998
1046
  freshChat: next.freshChat,
@@ -1001,6 +1049,10 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1001
1049
  transcript,
1002
1050
  onThinking: () => ui.thinking(),
1003
1051
  onSendPause: (seconds) => ui.sendPause(seconds),
1052
+ onSendState: (state) => {
1053
+ if (editor)
1054
+ editor.setSendState(state);
1055
+ },
1004
1056
  onNotice: (msg) => ui.warning(msg),
1005
1057
  onToolCall: (name, toolArgs) => {
1006
1058
  taskTools++;
@@ -1034,12 +1086,30 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1034
1086
  ui.warning(outcome);
1035
1087
  transcript?.log('agent_no_answer', { outcome });
1036
1088
  }
1037
- // T7: one dim summary line per task (duration + tool calls). Printed
1038
- // even on an abort, so the operator sees what happened.
1039
- const summary = theme.dim(t('msg.task_summary', {
1040
- sec: ((Date.now() - taskStart) / 1000).toFixed(1),
1041
- tools: String(taskTools),
1042
- }));
1089
+ // T7: one summary line per task (duration + tool calls + tokens spent).
1090
+ // Printed even on an abort, so the operator sees what happened. Duration
1091
+ // is human-readable ("45s", "1m 20s", "1h 12m 45s") with LOCALIZED unit
1092
+ // labels, instead of a raw seconds count like "3129.2s". The token part
1093
+ // is the DELTA of the cumulative counter for THIS task; it is omitted
1094
+ // when the counter is unknown.
1095
+ const tokensAfter = getTokenUsage ? getTokenUsage() : null;
1096
+ const tokenDelta = typeof tokensBefore === 'number' && typeof tokensAfter === 'number'
1097
+ ? Math.max(0, tokensAfter - tokensBefore)
1098
+ : null;
1099
+ const summaryParts = [
1100
+ t('task_sum.dur', {
1101
+ dur: formatDuration(Date.now() - taskStart, {
1102
+ h: t('dur.h'),
1103
+ m: t('dur.m'),
1104
+ s: t('dur.s'),
1105
+ }),
1106
+ }),
1107
+ t('task_sum.tools', { n: String(taskTools) }),
1108
+ ];
1109
+ if (tokenDelta !== null) {
1110
+ summaryParts.push(t('task_sum.tokens', { n: formatCompactTokens(tokenDelta) }));
1111
+ }
1112
+ const summary = theme.taskSummary('· ' + summaryParts.join(' · '));
1043
1113
  if (editor)
1044
1114
  editor.printAbove(summary);
1045
1115
  else
@@ -1101,6 +1171,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1101
1171
  // start a stale spinner.
1102
1172
  browser.onSendStart = null;
1103
1173
  browser.onSendPause = null;
1174
+ browser.onSendState = null;
1104
1175
  browser.onNotice = null;
1105
1176
  }
1106
1177
  }
@@ -1253,11 +1324,53 @@ async function main() {
1253
1324
  let lastChats = [];
1254
1325
  let currentChatId = null;
1255
1326
  let running = true;
1327
+ // Long-lived session goal (set via /goal). Prepended to every task message so
1328
+ // the model keeps the big picture across many turns. Persisted per working
1329
+ // directory in <project>/.zames-goal (best-effort); restored right away.
1330
+ let sessionGoal = await loadGoal(currentWorkdir);
1331
+ if (sessionGoal) {
1332
+ console.log(theme.system(t('goal.loaded', { goal: sessionGoal })));
1333
+ }
1334
+ // Scheduled tasks (/loop, /cron). A pure Scheduler holds the jobs; a
1335
+ // 1-second ticker enqueues a due job's task into the SAME message queue the
1336
+ // operator uses, so it runs when the agent is free (never mid-generation,
1337
+ // never bypassing the send throttle). Started lazily on the first job.
1338
+ const scheduler = new Scheduler();
1339
+ let scheduleTimer = null;
1340
+ const startScheduleTicker = () => {
1341
+ if (scheduleTimer)
1342
+ return;
1343
+ scheduleTimer = setInterval(() => {
1344
+ const due = scheduler.due(Date.now());
1345
+ for (const job of due) {
1346
+ pendingQueue.push({ text: job.task });
1347
+ transcript.log('scheduled_fire', { id: job.id, task: job.task });
1348
+ if (editor) {
1349
+ editor.printAbove(theme.system(t('sched.fired', { id: String(job.id), task: job.task })));
1350
+ }
1351
+ else {
1352
+ console.log(theme.system(t('sched.fired', { id: String(job.id), task: job.task })));
1353
+ }
1354
+ // Wake a waiting main loop so a queued job runs even when idle.
1355
+ if (waiter) {
1356
+ const r = waiter;
1357
+ waiter = null;
1358
+ r(pendingQueue.shift() ?? null);
1359
+ }
1360
+ }
1361
+ }, 1000);
1362
+ if (scheduleTimer.unref)
1363
+ scheduleTimer.unref();
1364
+ };
1256
1365
  // Messages the user typed while the agent worked. runTask takes them one
1257
1366
  // by one after the current task finishes.
1258
1367
  const pendingQueue = [];
1259
1368
  // Show the "no clipboard image" hint only once per session.
1260
1369
  let clipboardWarned = false;
1370
+ // Show the "message is queued" hint once per session (the first time the
1371
+ // operator sends while a task runs), so the queue is understood without
1372
+ // spamming the hint on every queued message.
1373
+ let queueHintShown = false;
1261
1374
  // Timestamp of the last Ctrl+C while the agent was busy. Two presses
1262
1375
  // within 2s escalate from "abort the running tool" to "stop the whole run".
1263
1376
  let lastCtrlCAt = 0;
@@ -1451,6 +1564,39 @@ async function main() {
1451
1564
  return att;
1452
1565
  };
1453
1566
  ed.onSubmit = (text, items) => {
1567
+ // While the agent is busy the main command loop is blocked on
1568
+ // `await runTask()`, so ONLY commands that make sense without the main
1569
+ // loop are handled here (a queued slash-command would run too late).
1570
+ if (ed.busy) {
1571
+ const q = parseQueueCommand(text);
1572
+ if (q) {
1573
+ if (q.sub === 'clear') {
1574
+ const n = pendingQueue.length;
1575
+ pendingQueue.length = 0;
1576
+ ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
1577
+ }
1578
+ else if (!pendingQueue.length) {
1579
+ ed.printAbove(theme.dim(t('msg.queue_empty')));
1580
+ }
1581
+ else {
1582
+ ed.printAbove(theme.system(t('msg.queue_title')));
1583
+ for (const line of formatQueueList(pendingQueue)) {
1584
+ ed.printAbove(theme.assistant(line));
1585
+ }
1586
+ ed.printAbove(theme.dim(t('msg.queue_cleared_hint')));
1587
+ }
1588
+ return;
1589
+ }
1590
+ // Toggle Deep thinking / Smart search LIVE. This only flips the
1591
+ // browser's DESIRED state and the status icons; it does not touch the
1592
+ // in-flight generation, so it is safe mid-run. The actual DeepSeek
1593
+ // toggles are applied on the next send by _applyToggles().
1594
+ const tg = parseLiveToggle(text);
1595
+ if (tg) {
1596
+ applyLiveToggle(tg);
1597
+ return;
1598
+ }
1599
+ }
1454
1600
  const attachments = (items || []).map((a) => ({
1455
1601
  path: a.path,
1456
1602
  name: a.name,
@@ -1458,6 +1604,14 @@ async function main() {
1458
1604
  }));
1459
1605
  const msg = { text, attachments };
1460
1606
  pendingQueue.push(msg);
1607
+ // The operator typed this WHILE the agent was working, so the message is
1608
+ // queued and only sent after the current task. Explain that once per
1609
+ // session (via the editor's own status area) so it is clear nothing was
1610
+ // lost; later queued messages stay as the compact msg.queued banner only.
1611
+ if (ed.busy && !queueHintShown) {
1612
+ queueHintShown = true;
1613
+ ed.printAbove(theme.dim(t('msg.queued_hint')));
1614
+ }
1461
1615
  if (waiter) {
1462
1616
  const r = waiter;
1463
1617
  waiter = null;
@@ -1577,6 +1731,40 @@ async function main() {
1577
1731
  }
1578
1732
  }
1579
1733
  }
1734
+ // Apply `/thinking` / `/web` immediately: flip the browser's DESIRED state
1735
+ // and the status icons, and persist to config (so it survives a restart).
1736
+ // The DeepSeek toggles themselves are clicked on the next send by
1737
+ // _applyToggles(), so this is safe even while a generation is in flight.
1738
+ function applyLiveToggle(cmd) {
1739
+ const b = browser;
1740
+ const cur = cmd.kind === 'thinking' ? b.deepThinking : b.webSearch;
1741
+ const next = cmd.mode === 'toggle' ? !cur : cmd.mode === 'on';
1742
+ if (cmd.kind === 'thinking')
1743
+ b.deepThinking = next;
1744
+ else
1745
+ b.webSearch = next;
1746
+ b.setToggleState({
1747
+ deepThinking: b.deepThinking,
1748
+ webSearch: b.webSearch,
1749
+ });
1750
+ const cfgPath = cmd.kind === 'thinking' ? 'browser.deepThinking' : 'browser.webSearch';
1751
+ try {
1752
+ writeConfigValue('home', cfgPath, String(next));
1753
+ setConfigRuntime(cfgPath, next);
1754
+ }
1755
+ catch {
1756
+ // A failed persist must not break the toggle itself.
1757
+ }
1758
+ if (editor)
1759
+ editor.refreshStatus();
1760
+ const label = cmd.kind === 'thinking' ? t('toggle.thinking') : t('toggle.search');
1761
+ const stateText = next ? t('common.on') : t('common.off');
1762
+ const line = t('toggle.set', { name: label, state: stateText });
1763
+ if (editor)
1764
+ editor.printAbove(theme.system(line));
1765
+ else
1766
+ console.log(theme.system(line));
1767
+ }
1580
1768
  function configSetRaw(field, raw) {
1581
1769
  const value = validateConfigValue(field, raw);
1582
1770
  writeConfigValue('home', field.path, raw);
@@ -2126,37 +2314,145 @@ async function main() {
2126
2314
  console.log(theme.system(currentWorkdir));
2127
2315
  continue;
2128
2316
  }
2317
+ if (lower === '/goal' || lower.startsWith('/goal ')) {
2318
+ const g = parseGoalCommand(trimmed);
2319
+ if (g) {
2320
+ if (g.sub === 'clear') {
2321
+ sessionGoal = null;
2322
+ await saveGoal(currentWorkdir, null);
2323
+ console.log(theme.system(t('goal.cleared')));
2324
+ }
2325
+ else if (g.sub === 'show') {
2326
+ console.log(sessionGoal
2327
+ ? theme.system(t('goal.current', { goal: sessionGoal }))
2328
+ : theme.dim(t('goal.none')));
2329
+ }
2330
+ else {
2331
+ sessionGoal = g.goal;
2332
+ await saveGoal(currentWorkdir, g.goal);
2333
+ console.log(theme.system(t('goal.set', { goal: g.goal })));
2334
+ }
2335
+ continue;
2336
+ }
2337
+ }
2338
+ if (lower === '/loop' || lower.startsWith('/loop ')) {
2339
+ const rest = trimmed.slice('/loop'.length).trim();
2340
+ const sp = rest.indexOf(' ');
2341
+ const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
2342
+ const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
2343
+ const ms = parseInterval(intervalStr);
2344
+ if (!ms || !taskStr) {
2345
+ console.error(theme.error(t('sched.loop_usage')));
2346
+ continue;
2347
+ }
2348
+ const job = scheduler.addLoop(ms, taskStr, Date.now());
2349
+ startScheduleTicker();
2350
+ console.log(theme.system(t('sched.added', {
2351
+ id: String(job.id),
2352
+ when: formatInterval(ms),
2353
+ task: taskStr,
2354
+ })));
2355
+ continue;
2356
+ }
2357
+ if (lower === '/cron' || lower.startsWith('/cron ')) {
2358
+ const rest = trimmed.slice('/cron'.length).trim();
2359
+ // A cron expression is exactly 5 whitespace-separated fields; the task
2360
+ // is everything after them.
2361
+ const parts = rest.split(/\s+/);
2362
+ if (parts.length < 6) {
2363
+ console.error(theme.error(t('sched.cron_usage')));
2364
+ continue;
2365
+ }
2366
+ const cronExpr = parts.slice(0, 5).join(' ');
2367
+ const taskStr = parts.slice(5).join(' ').trim();
2368
+ if (!parseCron(cronExpr) || !taskStr) {
2369
+ console.error(theme.error(t('sched.cron_usage')));
2370
+ continue;
2371
+ }
2372
+ const job = scheduler.addCron(cronExpr, taskStr, Date.now());
2373
+ if (!job) {
2374
+ console.error(theme.error(t('sched.cron_usage')));
2375
+ continue;
2376
+ }
2377
+ startScheduleTicker();
2378
+ const nextIn = formatInterval(Math.max(0, job.nextAt - Date.now()));
2379
+ console.log(theme.system(t('sched.added', {
2380
+ id: String(job.id),
2381
+ when: cronExpr + ' (in ~' + nextIn + ')',
2382
+ task: taskStr,
2383
+ })));
2384
+ continue;
2385
+ }
2386
+ if (lower === '/jobs' || lower.startsWith('/jobs ')) {
2387
+ const sub = trimmed.slice('/jobs'.length).trim().toLowerCase();
2388
+ if (sub === 'clear') {
2389
+ const n = scheduler.clear();
2390
+ if (scheduleTimer) {
2391
+ clearInterval(scheduleTimer);
2392
+ scheduleTimer = null;
2393
+ }
2394
+ console.log(theme.system(t('sched.cleared', { n: String(n) })));
2395
+ continue;
2396
+ }
2397
+ if (sub.startsWith('rm ') || sub.startsWith('remove ')) {
2398
+ const id = Number(sub.replace(/^(rm|remove)\s+/, ''));
2399
+ if (!Number.isInteger(id) || !scheduler.remove(id)) {
2400
+ console.error(theme.error(t('sched.rm_usage')));
2401
+ continue;
2402
+ }
2403
+ if (!scheduler.count() && scheduleTimer) {
2404
+ clearInterval(scheduleTimer);
2405
+ scheduleTimer = null;
2406
+ }
2407
+ console.log(theme.system(t('sched.removed', { id: String(id) })));
2408
+ continue;
2409
+ }
2410
+ const jobs = scheduler.list();
2411
+ if (!jobs.length) {
2412
+ console.log(theme.dim(t('sched.none')));
2413
+ continue;
2414
+ }
2415
+ console.log(theme.system(t('sched.title')));
2416
+ for (const j of jobs) {
2417
+ const when = j.kind === 'loop'
2418
+ ? 'every ' + formatInterval(j.intervalMs || 0)
2419
+ : j.cron || '';
2420
+ const nextIn = formatInterval(Math.max(0, j.nextAt - Date.now()));
2421
+ console.log(theme.assistant(' #' + j.id + ' [' + when + ', next ~' + nextIn + '] ' + j.task));
2422
+ }
2423
+ console.log(theme.dim(t('sched.remove_hint')));
2424
+ continue;
2425
+ }
2129
2426
  if (lower === '/queue' || lower.startsWith('/queue ')) {
2130
- const sub = trimmed.slice('/queue'.length).trim().toLowerCase();
2131
- if (sub === 'clear' || sub === 'c' || sub === 'clean') {
2427
+ const q = parseQueueCommand(trimmed);
2428
+ if (!q) {
2429
+ console.error(theme.error(t('msg.queue_usage')));
2430
+ continue;
2431
+ }
2432
+ if (q.sub === 'clear') {
2132
2433
  const n = pendingQueue.length;
2133
2434
  pendingQueue.length = 0;
2134
2435
  console.log(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
2135
2436
  continue;
2136
2437
  }
2137
- if (sub && sub !== 'list' && sub !== 'ls') {
2138
- console.error(theme.error(t('msg.queue_usage')));
2139
- continue;
2140
- }
2141
2438
  if (!pendingQueue.length) {
2142
2439
  console.log(theme.dim(t('msg.queue_empty')));
2143
2440
  continue;
2144
2441
  }
2145
2442
  console.log(theme.system(t('msg.queue_title')));
2146
- pendingQueue.forEach((m, i) => {
2147
- const one = String(m.text || '')
2148
- .replace(/\s+/g, ' ')
2149
- .trim();
2150
- const shown = one.length > 80 ? one.slice(0, 80) + ' …' : one;
2151
- const att = m.attachments?.length
2152
- ? ' [+' + m.attachments.length + ']'
2153
- : '';
2154
- console.log(theme.system(' ') +
2155
- theme.assistant(t('msg.queue_item', { n: i + 1, text: shown + att })));
2156
- });
2443
+ for (const line of formatQueueList(pendingQueue)) {
2444
+ console.log(theme.assistant(line));
2445
+ }
2157
2446
  console.log(theme.dim(t('msg.queue_cleared_hint')));
2158
2447
  continue;
2159
2448
  }
2449
+ if (lower.startsWith('/thinking') || lower.startsWith('/web')) {
2450
+ const tg = parseLiveToggle(trimmed);
2451
+ if (tg) {
2452
+ applyLiveToggle(tg);
2453
+ continue;
2454
+ }
2455
+ }
2160
2456
  if (lower === '/mcp') {
2161
2457
  if (!mcpPool) {
2162
2458
  console.log(theme.dim(t('mcp.none')));
@@ -2347,6 +2643,11 @@ async function main() {
2347
2643
  console.log(theme.system(t('status.max_iter', { v: maxIter })));
2348
2644
  console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
2349
2645
  console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
2646
+ const statusToggles = browser.getToggleStatesSync();
2647
+ console.log(theme.system(t('status.toggles', {
2648
+ think: statusToggles.deepThinking ? yes : no,
2649
+ search: statusToggles.webSearch ? yes : no,
2650
+ })));
2350
2651
  console.log(theme.system(t('status.undo', {
2351
2652
  v: config.undo.enabled ? t('common.on') : t('common.off'),
2352
2653
  })));
@@ -2695,6 +2996,7 @@ async function main() {
2695
2996
  autoCompactPct: config.browser.autoCompactPct,
2696
2997
  contextLimit: config.ui.contextLimit,
2697
2998
  getTokenUsage: () => browser.getLastTokenUsage(),
2999
+ goal: sessionGoal,
2698
3000
  }, inputAttachments);
2699
3001
  }
2700
3002
  finally {
@@ -2710,6 +3012,8 @@ async function main() {
2710
3012
  }
2711
3013
  if (editor)
2712
3014
  editor.dispose();
3015
+ if (scheduleTimer)
3016
+ clearInterval(scheduleTimer);
2713
3017
  await browser.close().catch(() => { });
2714
3018
  await mod.closeWeb().catch(() => { });
2715
3019
  if (mcpPool)
package/dist/input.js CHANGED
@@ -205,26 +205,32 @@ export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
205
205
  return '';
206
206
  }
207
207
  const n = Math.round(tokens);
208
- let compact;
209
- if (n >= 1_000_000) {
210
- const m = n / 1_000_000;
211
- compact = (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
212
- }
213
- else if (n >= 1_000) {
214
- const k = n / 1_000;
215
- compact =
216
- (k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
217
- 'k';
218
- }
219
- else {
220
- compact = String(n);
221
- }
208
+ const compact = formatCompactTokens(n);
222
209
  const pct = Math.max(0, (n / limit) * 100);
223
210
  const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
224
211
  // "ctx:" marks this as the USED context size, so the operator does not have
225
212
  // to guess what "302k · 30%" means.
226
213
  return 'ctx: ' + compact + ' · ' + pctStr + '%';
227
214
  }
215
+ // Compact token count (10k, 125k, 1.5M). Exported so the per-task summary
216
+ // reuses the EXACT same "k/M" formatting as the context counter. Invalid
217
+ // input (null/NaN/negative) renders an empty string.
218
+ export function formatCompactTokens(tokens) {
219
+ if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
220
+ return '';
221
+ }
222
+ const n = Math.round(tokens);
223
+ if (n >= 1_000_000) {
224
+ const m = n / 1_000_000;
225
+ return (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
226
+ }
227
+ if (n >= 1_000) {
228
+ const k = n / 1_000;
229
+ return ((k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
230
+ 'k');
231
+ }
232
+ return String(n);
233
+ }
228
234
  export class LineEditor {
229
235
  promptStr;
230
236
  buf;
@@ -251,6 +257,12 @@ export class LineEditor {
251
257
  _showHint;
252
258
  _dotPhase;
253
259
  _thinkBase;
260
+ // Explicit lifecycle state of the send ('generating' | 'paused' |
261
+ // 'settled' | ''). The thinking spinner already shows ACTIVITY, but it does
262
+ // not tell a throttle pause from a real generation — a long reasoning turn
263
+ // and a wait for the rate-limit slot look identical. Only 'generating' shows
264
+ // a prefix (see _stateLabel); the field also gates the clean-up in stop().
265
+ _sendState;
254
266
  // The last rendered status row(s) (status + right-aligned context). Cached so
255
267
  // a re-render driven by TYPING (the buffer changed, the status did not) can
256
268
  // skip erasing and re-printing the status line entirely. On terminals like
@@ -333,6 +345,7 @@ export class LineEditor {
333
345
  this._showHint = true;
334
346
  this._dotPhase = 0;
335
347
  this._thinkBase = '';
348
+ this._sendState = '';
336
349
  this._lastStatusBlock = '';
337
350
  this._wasRaw = false;
338
351
  this._onData = (b) => this._handle(b);
@@ -668,31 +681,38 @@ export class LineEditor {
668
681
  // free (effective width = cols - 1) for the right-aligned context and for
669
682
  // the padded status row.
670
683
  const usable = Math.max(1, cols - 1);
671
- if (this.statusText) {
684
+ // An explicit lifecycle phase (generating/settled) is prefixed to the
685
+ // animated status, so the operator can tell a real generation from a
686
+ // quiet spinner. Empty when no phase is active (tool running, idle).
687
+ const stateLabel = this._stateLabel();
688
+ const statusText = stateLabel
689
+ ? stateLabel + ' ' + this.statusText
690
+ : this.statusText;
691
+ if (statusText) {
672
692
  if (ctxText) {
673
693
  // Right-align the context on the SAME row as the status. When the
674
694
  // status is too long to leave room, do NOT cram them together (that
675
695
  // pushed the trailing `%` past the right edge and it got truncated):
676
696
  // put the context on its own line instead.
677
- const space = usable - visLen(this.statusText) - visLen(ctxText);
697
+ const space = usable - visLen(statusText) - visLen(ctxText);
678
698
  if (space >= 2) {
679
- const row = this.statusText + ' '.repeat(space) + ctxText;
699
+ const row = statusText + ' '.repeat(space) + ctxText;
680
700
  statusOut = row + NL;
681
701
  top = visRows(row, cols);
682
702
  }
683
703
  else {
684
- statusOut = this.statusText + NL;
685
- top = visRows(this.statusText, cols);
704
+ statusOut = statusText + NL;
705
+ top = visRows(statusText, cols);
686
706
  const ctxRow = ' '.repeat(Math.max(0, usable - visLen(ctxText))) + ctxText;
687
707
  statusOut += ctxRow + NL;
688
708
  top += visRows(ctxRow, cols);
689
709
  }
690
710
  }
691
711
  else {
692
- statusOut = this.statusText + NL;
712
+ statusOut = statusText + NL;
693
713
  // The status may wrap onto several lines — we account for this,
694
714
  // otherwise the block erase misses and statuses pile up.
695
- top = visRows(this.statusText, cols);
715
+ top = visRows(statusText, cols);
696
716
  }
697
717
  }
698
718
  else if (ctxText) {
@@ -885,6 +905,28 @@ export class LineEditor {
885
905
  return '';
886
906
  return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
887
907
  }
908
+ // The lifecycle phase prefix for the status line, colored by meaning:
909
+ // generating (brown). There is NO prefix for 'settled' (the answer block
910
+ // already marks completion) and none for 'paused' (the base status itself
911
+ // reads "пауза Ns"). Returns '' when no phase is active, so an idle status
912
+ // is not prefixed.
913
+ _stateLabel() {
914
+ if (this._sendState === 'generating')
915
+ return (theme.brown(translate(this.locale)('state.generating')) + theme.dim(':'));
916
+ return '';
917
+ }
918
+ // Called by the browser layer at real lifecycle points: right before a
919
+ // send (generating), during a throttle/Continue pause (paused), and after
920
+ // the answer settled (settled). Any other value clears the phase.
921
+ setSendState(state) {
922
+ const next = state === 'generating' || state === 'paused' || state === 'settled'
923
+ ? state
924
+ : '';
925
+ if (next === this._sendState)
926
+ return;
927
+ this._sendState = next;
928
+ this._render();
929
+ }
888
930
  _startThinking() {
889
931
  if (this.pendingText) {
890
932
  this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
@@ -945,6 +987,9 @@ export class LineEditor {
945
987
  }
946
988
  stop() {
947
989
  this._stopDots();
990
+ // A finished send must not keep prefixing the NEXT status (e.g. a tool
991
+ // that starts right after the answer) with a stale "settled"/"generating".
992
+ this._sendState = '';
948
993
  this.setStatus('');
949
994
  }
950
995
  toolCall(name, args) {
@@ -968,30 +1013,16 @@ export class LineEditor {
968
1013
  assistant(msg) {
969
1014
  this.stop();
970
1015
  const rendered = renderMarkdown(msg);
971
- // Show the used context size at the END of the answer too. During
972
- // generation it lives in the status line, but ui.stop() clears that right
973
- // before this call, so the operator lost the number exactly when the
974
- // answer arrived. Read it fresh via onContextQuery and append it dimmed
975
- // under the answer divider.
976
- let ctxLine = '';
977
- if (this.onContextQuery) {
978
- try {
979
- const n = this.onContextQuery();
980
- const txt = formatTokenStatus(n, this.contextLimit);
981
- if (txt)
982
- ctxLine = NL + theme.dim(txt);
983
- }
984
- catch {
985
- // never break the render
986
- }
987
- }
1016
+ // The used context is NOT repeated under the answer: `_writeBlock` keeps
1017
+ // showing it right-aligned in the status line even after `stop()` clears
1018
+ // the spinner text (the `else if (ctxText)` branch), so printing it here
1019
+ // again was a duplicate of the same `accumulated_token_usage` number.
988
1020
  this.printAbove(NL +
989
1021
  theme.assistant(translate(this.locale)('editor.answer')) +
990
1022
  NL +
991
1023
  rendered +
992
1024
  NL +
993
- theme.dim('─'.repeat(60)) +
994
- ctxLine);
1025
+ theme.dim('─'.repeat(60)));
995
1026
  }
996
1027
  // Warning to the operator (e.g. the agent stopped suspiciously).
997
1028
  // Printed above the input line without overwriting it.
@@ -0,0 +1,185 @@
1
+ // Pure scheduling logic for the /loop and /cron commands. Kept free of any
2
+ // browser/terminal dependency so it can be unit-tested; index.ts owns the
3
+ // 1-second ticker and the actual enqueue into the message queue.
4
+ // Parse a human interval like "30s", "5m", "2h" or a bare number of seconds.
5
+ // Returns the period in ms, or null when the text is not a valid interval.
6
+ export function parseInterval(text) {
7
+ const m = /^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hour|hours)?$/i.exec(String(text ?? '').trim());
8
+ if (!m)
9
+ return null;
10
+ const n = Number(m[1]);
11
+ if (!Number.isFinite(n) || n <= 0)
12
+ return null;
13
+ const unit = (m[2] || 's').toLowerCase();
14
+ const mult = unit.startsWith('h')
15
+ ? 3_600_000
16
+ : unit.startsWith('m')
17
+ ? 60_000
18
+ : 1000;
19
+ return n * mult;
20
+ }
21
+ // Render an interval back to the shortest human form (used in listings).
22
+ export function formatInterval(ms) {
23
+ if (ms % 3_600_000 === 0)
24
+ return ms / 3_600_000 + 'h';
25
+ if (ms % 60_000 === 0)
26
+ return ms / 60_000 + 'm';
27
+ if (ms % 1000 === 0)
28
+ return ms / 1000 + 's';
29
+ return ms + 'ms';
30
+ }
31
+ // Parse one cron field ("*", "a-b", "*/n", "a,b", "a-b/n") into a value set.
32
+ function parseCronField(field, min, max) {
33
+ const out = new Set();
34
+ for (const part of field.split(',')) {
35
+ const [range, stepStr] = part.split('/');
36
+ const step = stepStr === undefined ? 1 : Number(stepStr);
37
+ if (!Number.isInteger(step) || step <= 0)
38
+ return null;
39
+ let lo;
40
+ let hi;
41
+ if (range === '*') {
42
+ lo = min;
43
+ hi = max;
44
+ }
45
+ else if (range.includes('-')) {
46
+ const [a, b] = range.split('-');
47
+ lo = Number(a);
48
+ hi = Number(b);
49
+ }
50
+ else {
51
+ lo = Number(range);
52
+ hi = lo;
53
+ }
54
+ if (!Number.isInteger(lo) || !Number.isInteger(hi))
55
+ return null;
56
+ if (lo < min || hi > max || lo > hi)
57
+ return null;
58
+ for (let v = lo; v <= hi; v += step)
59
+ out.add(v);
60
+ }
61
+ return out.size ? out : null;
62
+ }
63
+ // Parse a 5-field cron expression: minute hour day-of-month month day-of-week.
64
+ // Day-of-week accepts 0-7 (both 0 and 7 mean Sunday). Returns null on a bad
65
+ // expression so the command can report it instead of scheduling silently.
66
+ export function parseCron(expr) {
67
+ const parts = String(expr ?? '')
68
+ .trim()
69
+ .split(/\s+/);
70
+ if (parts.length !== 5)
71
+ return null;
72
+ const minute = parseCronField(parts[0], 0, 59);
73
+ const hour = parseCronField(parts[1], 0, 23);
74
+ const dom = parseCronField(parts[2], 1, 31);
75
+ const month = parseCronField(parts[3], 1, 12);
76
+ const dowRaw = parseCronField(parts[4], 0, 7);
77
+ if (!minute || !hour || !dom || !month || !dowRaw)
78
+ return null;
79
+ const dow = new Set();
80
+ for (const d of dowRaw)
81
+ dow.add(d === 7 ? 0 : d);
82
+ return { minute, hour, dom, month, dow };
83
+ }
84
+ // Does the given Date match the cron expression? dom and dow are ANDed (the
85
+ // common, predictable behavior); to schedule "every day" use `*` for one.
86
+ export function cronMatches(expr, date) {
87
+ const c = parseCron(expr);
88
+ if (!c)
89
+ return false;
90
+ return (c.minute.has(date.getMinutes()) &&
91
+ c.hour.has(date.getHours()) &&
92
+ c.dom.has(date.getDate()) &&
93
+ c.month.has(date.getMonth() + 1) &&
94
+ c.dow.has(date.getDay()));
95
+ }
96
+ // Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
97
+ // minute by minute (bounded to ~4 years); a one-time cost when a job is
98
+ // created/rescheduled, and the field checks reject almost every minute fast.
99
+ export function nextCronTime(expr, fromMs) {
100
+ const c = parseCron(expr);
101
+ if (!c)
102
+ return null;
103
+ const d = new Date(fromMs);
104
+ d.setSeconds(0, 0);
105
+ d.setMinutes(d.getMinutes() + 1);
106
+ const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
107
+ while (d.getTime() <= limit) {
108
+ if (cronMatches(expr, d))
109
+ return d.getTime();
110
+ d.setMinutes(d.getMinutes() + 1);
111
+ }
112
+ return null;
113
+ }
114
+ // Holds the scheduled jobs. Pure: no timers here. index.ts drives it with a
115
+ // 1-second tick and enqueues the tasks that `due()` returns.
116
+ export class Scheduler {
117
+ jobs = [];
118
+ nextId = 1;
119
+ addLoop(intervalMs, task, now) {
120
+ const job = {
121
+ id: this.nextId++,
122
+ kind: 'loop',
123
+ task,
124
+ intervalMs,
125
+ nextAt: now + intervalMs,
126
+ createdAt: now,
127
+ };
128
+ this.jobs.push(job);
129
+ return job;
130
+ }
131
+ addCron(cron, task, now) {
132
+ const nextAt = nextCronTime(cron, now);
133
+ if (nextAt === null)
134
+ return null;
135
+ const job = {
136
+ id: this.nextId++,
137
+ kind: 'cron',
138
+ task,
139
+ cron,
140
+ nextAt,
141
+ createdAt: now,
142
+ };
143
+ this.jobs.push(job);
144
+ return job;
145
+ }
146
+ remove(id) {
147
+ const i = this.jobs.findIndex((j) => j.id === id);
148
+ if (i === -1)
149
+ return false;
150
+ this.jobs.splice(i, 1);
151
+ return true;
152
+ }
153
+ clear() {
154
+ const n = this.jobs.length;
155
+ this.jobs = [];
156
+ return n;
157
+ }
158
+ list() {
159
+ return this.jobs.slice();
160
+ }
161
+ count() {
162
+ return this.jobs.length;
163
+ }
164
+ // Jobs whose nextAt is due at `now`, rescheduled for their NEXT fire so a
165
+ // single tick never fires the same job twice. A loop that fell behind is
166
+ // advanced past `now` in one step (missed fires are coalesced, not queued
167
+ // many times).
168
+ due(now) {
169
+ const fired = [];
170
+ for (const job of this.jobs) {
171
+ if (job.nextAt > now)
172
+ continue;
173
+ fired.push({ ...job });
174
+ if (job.kind === 'loop' && job.intervalMs && job.intervalMs > 0) {
175
+ const missed = Math.floor((now - job.nextAt) / job.intervalMs);
176
+ job.nextAt = job.nextAt + (missed + 1) * job.intervalMs;
177
+ }
178
+ else if (job.cron) {
179
+ const nxt = nextCronTime(job.cron, now);
180
+ job.nextAt = nxt === null ? Number.POSITIVE_INFINITY : nxt;
181
+ }
182
+ }
183
+ return fired;
184
+ }
185
+ }
package/dist/theme.js CHANGED
@@ -23,6 +23,7 @@ export const theme = {
23
23
  toolResult: chalk.hex('#8a9bb5'), // gray-blue
24
24
  system: chalk.hex('#808896'), // neutral gray
25
25
  dim: chalk.hex('#5b616e'), // dark gray
26
+ taskSummary: chalk.hex('#8fa3c8'), // per-task summary — noticeable but calm, distinct from spinner brown and warn ochre
26
27
  warn: chalk.hex('#c9a86a'), // calm ochre
27
28
  error: chalk.hex('#c98a80'), // muted terracotta
28
29
  success: chalk.hex('#a9c08c'), // soft sage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.32.0",
3
+ "version": "2.34.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {