zames_pro 2.55.0 → 2.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11,14 +11,14 @@ import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from '.
11
11
  import { runAgentLoop } from './agent-loop.js';
12
12
  import { createSpinner } from './spinner.js';
13
13
  import { LineEditor, expandPastes, pasteReplacement, formatCompactTokens, } from './input.js';
14
- import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, contentHash, } from './attachments.js';
14
+ import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
15
15
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
16
16
  import { runConfigMenu } from './config-menu.js';
17
17
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
18
18
  import { Transcript } from './transcript.js';
19
19
  import { UndoStore } from './undo.js';
20
20
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
21
- import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, resolveExtraDir, buildReviewPrompt, formatDuration, formatRelativeTime, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
21
+ import { formatDiff, formatDiffStat, formatContextSources, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, resolveExtraDir, buildReviewPrompt, formatDuration, formatRelativeTime, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, hasQueuedJob, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, isLiveConfigCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
22
22
  import { performCompact } from './compact.js';
23
23
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
24
24
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
@@ -27,6 +27,10 @@ import { assertCommandInsideRoot, runShell } from './shell.js';
27
27
  import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
28
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
29
  const NL = String.fromCharCode(10);
30
+ // The last final assistant message, for the /copy command. Module-level
31
+ // because runTask() (which sees the answers) and the main loop (which handles
32
+ // /copy) are separate functions.
33
+ let lastAssistantMessage = '';
30
34
  // Floor for a /loop interval. A loop faster than the send throttle (15s)
31
35
  // cannot do anything useful and would just hammer the rate limit, so we refuse
32
36
  // it. It does NOT touch the throttle itself.
@@ -54,7 +58,7 @@ function getPositional() {
54
58
  const positional = [];
55
59
  for (let i = 0; i < args.length; i++) {
56
60
  const a = args[i];
57
- if (['--dir', '--task', '--max-iter', '--project', '--chat'].includes(a)) {
61
+ if (['--dir', '--task', '--max-iter', '--chat', '--output-format'].includes(a)) {
58
62
  i++;
59
63
  continue;
60
64
  }
@@ -88,7 +92,6 @@ const headless = hasFlag('--headed')
88
92
  ? false
89
93
  : hasFlag('--headless') || config.headless;
90
94
  const debug = hasFlag('--debug') || config.debug;
91
- const calibrate = hasFlag('--calibrate');
92
95
  const maxIterArg = getArg('--max-iter', null);
93
96
  const maxIter = maxIterArg !== null ? Number(maxIterArg) : config.maxIterations;
94
97
  const positional = getPositional();
@@ -103,6 +106,23 @@ const resendPrompt = hasFlag('--resend-prompt');
103
106
  // --new-chat: kept for compatibility — this is the default behavior anyway.
104
107
  const newChatFlag = hasFlag('--new-chat');
105
108
  const resumeLastFlag = hasFlag('--resume-last');
109
+ // Plan mode (read-only): start with it ON via --plan, toggle with /plan. When
110
+ // on, mutating tools are filtered out of the tool set entirely (see
111
+ // filterToolsForReadOnly). Declared here because tool creation happens before
112
+ // the main loop.
113
+ let planMode = hasFlag('--plan');
114
+ // Machine-readable output for scripts/CI. 'text' (default) is the normal
115
+ // human UI; 'json'/'jsonl' stream the transcript events as JSON lines on
116
+ // stdout, while all human text is pushed to stderr so the stream stays clean.
117
+ // Meant for one-shot mode (--task).
118
+ const outputFormat = (getArg('--output-format', 'text') || 'text').toLowerCase();
119
+ const jsonMode = outputFormat === 'json' || outputFormat === 'jsonl';
120
+ if (jsonMode) {
121
+ // Keep stdout for the event stream only.
122
+ console.log = ((...a) => {
123
+ console.error(...a);
124
+ });
125
+ }
106
126
  // ---------- session persistence ----------
107
127
  // We store sessions (DeepSeek chats) in ~/.zames/.sessions so they survive
108
128
  // a restart and are available for explicit restore (--resume-last,
@@ -171,6 +191,7 @@ const RELOADABLE = [
171
191
  'transcript',
172
192
  'spinner',
173
193
  'config',
194
+ 'fsutil',
174
195
  ];
175
196
  const mod = {
176
197
  createTools,
@@ -289,8 +310,9 @@ ${theme.bold(t('help.options'))}
289
310
  --max-iter <n> ${t('help.opt.max_iter', { n: config.maxIterations })}
290
311
  --headless ${t('help.opt.headless')}
291
312
  --headed ${t('help.opt.headed')}
313
+ --plan ${t('help.opt.plan')}
314
+ --output-format <f> ${t('help.opt.output_format')}
292
315
  --debug ${t('help.opt.debug')}
293
- --calibrate ${t('help.opt.calibrate')}
294
316
  --dev ${t('help.opt.dev')}
295
317
  --no-color ${t('help.opt.no_color')}
296
318
  --version, -v ${t('help.opt.version')}
@@ -312,42 +334,60 @@ ${theme.bold(t('help.while_working'))}
312
334
  ${t('help.key.bang')}
313
335
 
314
336
  ${theme.bold(t('help.commands'))}
337
+
338
+ ${theme.bold(t('help.sec.session'))}
315
339
  ${t('help.cmd.new')}
316
340
  ${t('help.cmd.sessions')}
317
- ${t('help.cmd.resume_id')}
318
341
  ${t('help.cmd.chats')}
319
342
  ${t('help.cmd.resume')}
343
+ ${t('help.cmd.resume_id')}
320
344
  ${t('help.cmd.last')}
345
+ ${t('help.cmd.retry')}
346
+ ${t('help.cmd.rename')}
347
+
348
+ ${theme.bold(t('help.sec.workspace'))}
321
349
  ${t('help.cmd.chat')}
322
350
  ${t('help.cmd.cd')}
323
351
  ${t('help.cmd.pwd')}
324
352
  ${t('help.cmd.status')}
325
353
  ${t('help.cmd.reload')}
326
- ${t('help.cmd.undo')}
327
- ${t('help.cmd.undo_list')}
328
- ${t('help.cmd.transcript')}
354
+
355
+ ${theme.bold(t('help.sec.git'))}
329
356
  ${t('help.cmd.diff')}
330
- ${t('help.cmd.cost')}
331
- ${t('help.cmd.export')}
332
- ${t('help.cmd.doctor')}
333
- ${t('help.cmd.add_dir')}
334
- ${t('help.cmd.review')}
357
+ ${t('help.cmd.diffstat')}
358
+
359
+ ${theme.bold(t('help.sec.agent'))}
335
360
  ${t('help.cmd.goal')}
336
361
  ${t('help.cmd.loop')}
337
362
  ${t('help.cmd.cron')}
338
363
  ${t('help.cmd.jobs')}
339
364
  ${t('help.cmd.thinking')}
340
365
  ${t('help.cmd.web')}
366
+ ${t('help.cmd.plan')}
341
367
  ${t('help.cmd.compact')}
342
368
  ${t('help.cmd.queue')}
343
369
  ${t('help.cmd.tasks')}
344
- ${t('help.cmd.config')}
345
- ${t('help.cmd.lang')}
370
+
371
+ ${theme.bold(t('help.sec.context'))}
372
+ ${t('help.cmd.context')}
346
373
  ${t('help.cmd.skills')}
347
374
  ${t('help.cmd.memory')}
348
375
  ${t('help.cmd.remember')}
349
376
  ${t('help.cmd.init')}
350
377
  ${t('help.cmd.mcp')}
378
+ ${t('help.cmd.config')}
379
+ ${t('help.cmd.lang')}
380
+
381
+ ${theme.bold(t('help.sec.files'))}
382
+ ${t('help.cmd.transcript')}
383
+ ${t('help.cmd.undo')}
384
+ ${t('help.cmd.undo_list')}
385
+ ${t('help.cmd.export')}
386
+ ${t('help.cmd.cost')}
387
+ ${t('help.cmd.copy')}
388
+ ${t('help.cmd.doctor')}
389
+ ${t('help.cmd.add_dir')}
390
+ ${t('help.cmd.review')}
351
391
  ${t('help.cmd.debug_dom')}
352
392
  ${t('help.cmd.help')}
353
393
  ${t('help.cmd.exit')}
@@ -389,6 +429,10 @@ const SLASH_COMMANDS = [
389
429
  { name: '/resume', key: 'help.cmd.resume' },
390
430
  { name: '/resume-id', key: 'help.cmd.resume_id' },
391
431
  { name: '/last', key: 'help.cmd.last' },
432
+ { name: '/retry', key: 'help.cmd.retry' },
433
+ { name: '/rename', key: 'help.cmd.rename' },
434
+ { name: '/context', key: 'help.cmd.context' },
435
+ { name: '/copy', key: 'help.cmd.copy' },
392
436
  { name: '/chat', key: 'help.cmd.chat' },
393
437
  { name: '/cd', key: 'help.cmd.cd' },
394
438
  { name: '/pwd', key: 'help.cmd.pwd' },
@@ -398,6 +442,7 @@ const SLASH_COMMANDS = [
398
442
  { name: '/undo-list', key: 'help.cmd.undo_list' },
399
443
  { name: '/transcript', key: 'help.cmd.transcript' },
400
444
  { name: '/diff', key: 'help.cmd.diff' },
445
+ { name: '/diffstat', key: 'help.cmd.diffstat' },
401
446
  { name: '/cost', key: 'help.cmd.cost' },
402
447
  { name: '/export', key: 'help.cmd.export' },
403
448
  { name: '/doctor', key: 'help.cmd.doctor' },
@@ -409,6 +454,7 @@ const SLASH_COMMANDS = [
409
454
  { name: '/jobs', key: 'help.cmd.jobs' },
410
455
  { name: '/thinking', key: 'help.cmd.thinking' },
411
456
  { name: '/web', key: 'help.cmd.web' },
457
+ { name: '/plan', key: 'help.cmd.plan' },
412
458
  { name: '/compact', key: 'help.cmd.compact' },
413
459
  { name: '/queue', key: 'help.cmd.queue' },
414
460
  { name: '/tasks', key: 'help.cmd.tasks' },
@@ -1008,7 +1054,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
1008
1054
  }
1009
1055
  // ---------- task runner ----------
1010
1056
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
1011
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, } = opts;
1057
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, } = opts;
1012
1058
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
1013
1059
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
1014
1060
  // mode (pipes) — a regular spinner + watchInput.
@@ -1016,6 +1062,12 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1016
1062
  browser._stopped = false;
1017
1063
  browser._abort = false;
1018
1064
  const ui = editor || mod.createSpinner(currentLocale);
1065
+ // Non-TTY parity: the ora spinner shows the same "tasks: 2/5" badge the
1066
+ // LineEditor shows (the editor gets it via onTasksQuery in main()).
1067
+ if (!editor && todosQuery && 'onTasksQuery' in ui) {
1068
+ ;
1069
+ ui.onTasksQuery = todosQuery;
1070
+ }
1019
1071
  // Mark the editor busy for the WHOLE task, not only for /init and /self-fix.
1020
1072
  // Esc / Ctrl+C abort the current generation only while busy; without this a
1021
1073
  // long-running tool (Bash, npm, MCP) could not be interrupted — Esc did
@@ -1089,7 +1141,14 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1089
1141
  ui.toolCall(name, toolArgs);
1090
1142
  },
1091
1143
  onToolResult: (result) => ui.toolResult(result),
1092
- onAssistantMessage: (msg) => ui.assistant(msg),
1144
+ onAssistantMessage: (msg) => {
1145
+ // Remember the last answer so /copy can put it on the clipboard
1146
+ // without re-reading the chat. Wrapped: a UI failure is swallowed
1147
+ // by agent-loop anyway.
1148
+ lastAssistantMessage = msg;
1149
+ opts.onAssistantFinal?.(msg);
1150
+ ui.assistant(msg);
1151
+ },
1093
1152
  onWarning: (msg) => ui.warning(msg),
1094
1153
  onChatReady,
1095
1154
  debugLog: debug,
@@ -1221,9 +1280,6 @@ async function main() {
1221
1280
  printHelp();
1222
1281
  return;
1223
1282
  }
1224
- if (calibrate) {
1225
- console.log(theme.warn(t('calibrate')));
1226
- }
1227
1283
  await cleanTmpDir();
1228
1284
  let currentWorkdir;
1229
1285
  let sandboxRoot;
@@ -1242,6 +1298,9 @@ async function main() {
1242
1298
  dir: config.transcript.dir,
1243
1299
  enabled: config.transcript.enabled,
1244
1300
  sessionName: dirLabel(currentWorkdir),
1301
+ // In json/jsonl mode mirror every event to stdout as a JSON line, even
1302
+ // when the transcript FILE is disabled.
1303
+ onLine: jsonMode ? (line) => process.stdout.write(line) : null,
1245
1304
  });
1246
1305
  if (transcript.file) {
1247
1306
  console.log(theme.system(t('msg.transcript', { v: transcript.file })));
@@ -1263,6 +1322,8 @@ async function main() {
1263
1322
  if (srv.error)
1264
1323
  console.error(theme.warn(t('mcp.server_error', { name: srv.name, error: srv.error })));
1265
1324
  }
1325
+ for (const w of st.warnings)
1326
+ console.error(theme.warn(w));
1266
1327
  }
1267
1328
  catch (e) {
1268
1329
  console.error(theme.warn(t('mcp.load_failed', { v: e.message })));
@@ -1303,7 +1364,10 @@ async function main() {
1303
1364
  }
1304
1365
  // One-shot mode
1305
1366
  if (task) {
1306
- const tools = mod.createTools(currentWorkdir, { undo });
1367
+ const tools = mod.createTools(currentWorkdir, {
1368
+ undo,
1369
+ readOnly: planMode,
1370
+ });
1307
1371
  if (mcpPool)
1308
1372
  tools.push(...mcpPool.tools);
1309
1373
  let freshChat = true;
@@ -1366,6 +1430,8 @@ async function main() {
1366
1430
  const promptOnResume = () => resendPrompt || config.browser.resendPromptOnResume === true;
1367
1431
  let lastChats = [];
1368
1432
  let currentChatId = null;
1433
+ // The last task the operator sent (used by /retry). Plain text only.
1434
+ let lastTaskText = '';
1369
1435
  let running = true;
1370
1436
  // The agent's TodoWrite checklist, shared with the tool factory and
1371
1437
  // persisted in the session JSON. A store (not module state) so /reload does
@@ -2746,6 +2812,28 @@ async function main() {
2746
2812
  continue;
2747
2813
  }
2748
2814
  }
2815
+ if (lower === '/plan' || lower.startsWith('/plan ')) {
2816
+ const arg = trimmed.slice('/plan'.length).trim().toLowerCase();
2817
+ if (arg === '' || arg === 'toggle')
2818
+ planMode = !planMode;
2819
+ else if (arg === 'on' || arg === '1' || arg === 'true' || arg === 'вкл')
2820
+ planMode = true;
2821
+ else if (arg === 'off' ||
2822
+ arg === '0' ||
2823
+ arg === 'false' ||
2824
+ arg === 'выкл')
2825
+ planMode = false;
2826
+ else {
2827
+ console.error(theme.warn(t('msg.unknown_cmd', { v: trimmed })));
2828
+ continue;
2829
+ }
2830
+ const line = t(planMode ? 'plan.on' : 'plan.off');
2831
+ if (editor)
2832
+ editor.printAbove(theme.system(line));
2833
+ else
2834
+ console.log(theme.system(line));
2835
+ continue;
2836
+ }
2749
2837
  if (lower === '/mcp') {
2750
2838
  if (!mcpPool) {
2751
2839
  console.log(theme.dim(t('mcp.none')));
@@ -2768,6 +2856,8 @@ async function main() {
2768
2856
  theme.dim('(' + String(srv.tools.length) + ')'));
2769
2857
  }
2770
2858
  }
2859
+ for (const w of st.warnings)
2860
+ console.log(theme.warn(' ' + w));
2771
2861
  continue;
2772
2862
  }
2773
2863
  if (lower === '/skills') {
@@ -2999,6 +3089,98 @@ async function main() {
2999
3089
  console.log(theme.system(transcript.file || t('common.off')));
3000
3090
  continue;
3001
3091
  }
3092
+ if (lower === '/context') {
3093
+ // Show what buildSystemPrompt() injects (AGENTS/MEMORY/skills/commands)
3094
+ // so the operator can see why the context is large. Best-effort: a broken
3095
+ // file must not break the command.
3096
+ try {
3097
+ const { loadProjectContext } = await import('./context.js');
3098
+ const ctx = await loadProjectContext(currentWorkdir);
3099
+ const sysLen = mod.buildSystemPrompt({
3100
+ workdir: currentWorkdir,
3101
+ tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
3102
+ locale: currentLocale,
3103
+ context: ctx,
3104
+ }).length;
3105
+ console.log(theme.system(formatContextSources(ctx.agents, ctx.memory, ctx.skills, ctx.commands, { systemPromptChars: sysLen }, t)));
3106
+ }
3107
+ catch (e) {
3108
+ console.error(theme.error(e.message));
3109
+ }
3110
+ continue;
3111
+ }
3112
+ if (lower === '/rename' || lower.startsWith('/rename ')) {
3113
+ const title = trimmed.slice('/rename'.length).trim();
3114
+ if (!title) {
3115
+ console.log(theme.dim(t('rename.usage')));
3116
+ continue;
3117
+ }
3118
+ if (!currentChatId) {
3119
+ currentChatId = await browser.getCurrentChatId();
3120
+ }
3121
+ if (!currentChatId) {
3122
+ console.error(theme.error(t('chats.not_created')));
3123
+ continue;
3124
+ }
3125
+ saveLastChat(currentChatId, currentWorkdir, title);
3126
+ console.log(theme.assistant(t('rename.done', { v: title })));
3127
+ continue;
3128
+ }
3129
+ if (lower === '/copy') {
3130
+ if (!lastAssistantMessage) {
3131
+ console.log(theme.dim(t('copy.none')));
3132
+ continue;
3133
+ }
3134
+ const res = writeClipboardText(lastAssistantMessage);
3135
+ if (res.ok) {
3136
+ console.log(theme.assistant(t('copy.done', { tool: res.via })));
3137
+ }
3138
+ else {
3139
+ console.error(theme.warn(t('copy.failed', { tool: res.via })));
3140
+ }
3141
+ continue;
3142
+ }
3143
+ if (lower === '/retry') {
3144
+ if (!lastTaskText) {
3145
+ console.log(theme.dim(t('retry.none')));
3146
+ continue;
3147
+ }
3148
+ console.log(theme.system(t('retry.running')));
3149
+ // Re-send the last task into the SAME chat (no new chat, no system
3150
+ // prompt), so a truncated/empty answer can be retried cheaply.
3151
+ const tools = mod.createTools(currentWorkdir, {
3152
+ undo,
3153
+ todos: todoStore,
3154
+ readOnly: planMode,
3155
+ });
3156
+ if (mcpPool)
3157
+ tools.push(...mcpPool.tools);
3158
+ if (editor)
3159
+ editor.busy = true;
3160
+ try {
3161
+ await runTask(browser, tools, lastTaskText, currentWorkdir, {
3162
+ transcript,
3163
+ freshChat: false,
3164
+ sendSystemPrompt: false,
3165
+ queue: pendingQueue,
3166
+ ui: editor || null,
3167
+ onChatReady: (chatId) => {
3168
+ if (chatId) {
3169
+ currentChatId = chatId;
3170
+ saveLastChat(chatId, currentWorkdir);
3171
+ }
3172
+ },
3173
+ contextLimit: config.ui.contextLimit,
3174
+ getTokenUsage: () => browser.getLastTokenUsage(),
3175
+ goal: sessionGoal,
3176
+ });
3177
+ }
3178
+ finally {
3179
+ if (editor)
3180
+ editor.busy = false;
3181
+ }
3182
+ continue;
3183
+ }
3002
3184
  if (lower === '/diff' || lower.startsWith('/diff ')) {
3003
3185
  const staged = lower.indexOf('--staged') !== -1;
3004
3186
  const { runGit } = await import('./gitTools.js');
@@ -3011,6 +3193,17 @@ async function main() {
3011
3193
  console.log(theme.system(formatDiff(out, { maxLines: 400 }, t)));
3012
3194
  continue;
3013
3195
  }
3196
+ if (lower === '/diffstat') {
3197
+ const { runGit } = await import('./gitTools.js');
3198
+ const probe = await runGit('git rev-parse --is-inside-work-tree', currentWorkdir, 5000);
3199
+ if (probe.trim() !== 'true') {
3200
+ console.error(theme.error(t('diff.not_repo')));
3201
+ continue;
3202
+ }
3203
+ const out = await runGit('git diff --stat', currentWorkdir, 20_000);
3204
+ console.log(theme.system(formatDiffStat(out, { maxLines: 60 }, t)));
3205
+ continue;
3206
+ }
3014
3207
  if (lower === '/cost' || lower === '/usage') {
3015
3208
  let stats = summarizeTranscript([]);
3016
3209
  if (transcript.file) {
@@ -3182,7 +3375,15 @@ async function main() {
3182
3375
  transcript.log('undo', { path: result.record.originalPath });
3183
3376
  }
3184
3377
  else {
3185
- console.error(theme.error(t('undo.failed', { v: String(result.reason ?? '') })));
3378
+ // undo.ts returns machine codes ('empty'/'disabled') — localize here so
3379
+ // an English UI never shows a Russian reason.
3380
+ const reasonKey = result.reason === 'empty'
3381
+ ? 'undo.reason_empty'
3382
+ : result.reason === 'disabled'
3383
+ ? 'undo.reason_disabled'
3384
+ : null;
3385
+ const reason = reasonKey ? t(reasonKey) : String(result.reason ?? '');
3386
+ console.error(theme.error(t('undo.failed', { v: reason })));
3186
3387
  }
3187
3388
  continue;
3188
3389
  }
@@ -3266,6 +3467,7 @@ async function main() {
3266
3467
  }
3267
3468
  // ---- Regular task (including in review mode) ----
3268
3469
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3470
+ lastTaskText = taskText;
3269
3471
  // Dev mode: pick up fresh logic modules before the task.
3270
3472
  await autoReload();
3271
3473
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3275,7 +3477,11 @@ async function main() {
3275
3477
  if (editor)
3276
3478
  editor.setCommands(buildSlashCommands());
3277
3479
  transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
3278
- const tools = mod.createTools(currentWorkdir, { undo, todos: todoStore });
3480
+ const tools = mod.createTools(currentWorkdir, {
3481
+ undo,
3482
+ todos: todoStore,
3483
+ readOnly: planMode,
3484
+ });
3279
3485
  if (mcpPool)
3280
3486
  tools.push(...mcpPool.tools);
3281
3487
  if (editor)
@@ -3330,6 +3536,7 @@ async function main() {
3330
3536
  contextLimit: config.ui.contextLimit,
3331
3537
  getTokenUsage: () => browser.getLastTokenUsage(),
3332
3538
  goal: sessionGoal,
3539
+ todosQuery: () => todosSummary(),
3333
3540
  }, inputAttachments);
3334
3541
  }
3335
3542
  finally {
package/dist/mcp.js CHANGED
@@ -16,6 +16,24 @@ function packageVersion() {
16
16
  }
17
17
  return '0.0.0';
18
18
  }
19
+ /**
20
+ * Flag stdio servers pinned to a floating version (`@latest` / `@next`).
21
+ * A floating version can change between runs and silently alter the tools the
22
+ * agent sees, so the operator is warned and told to pin an exact version.
23
+ * Pure; exported for tests.
24
+ */
25
+ export function floatingVersionWarnings(servers) {
26
+ const out = [];
27
+ for (const [name, cfg] of Object.entries(servers)) {
28
+ if (cfg.disabled)
29
+ continue;
30
+ const args = [String(cfg.command || ''), ...(cfg.args || [])].join(' ');
31
+ if (/@(latest|next)\b/.test(args)) {
32
+ out.push(name);
33
+ }
34
+ }
35
+ return out;
36
+ }
19
37
  /** Names of the servers found in a config, before any connection. */
20
38
  export async function listConfiguredServers(workdir) {
21
39
  const files = await discoverConfigFiles(workdir);
@@ -298,6 +316,7 @@ export async function createMcpPool(opts) {
298
316
  const config = await loadConfigFiles(files);
299
317
  const servers = config.mcpServers || {};
300
318
  const names = Object.keys(servers).filter((n) => !servers[n].disabled);
319
+ const floating = floatingVersionWarnings(servers);
301
320
  const infos = [];
302
321
  const connections = [];
303
322
  const tools = [];
@@ -339,6 +358,7 @@ export async function createMcpPool(opts) {
339
358
  status: () => ({
340
359
  servers: infos,
341
360
  toolCount: tools.length,
361
+ warnings: floating.map((n) => `MCP server "${n}" uses a floating version (@latest/@next); pin an exact version so the tool set does not change between runs.`),
342
362
  }),
343
363
  close: async () => {
344
364
  if (closed)
package/dist/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { writeJsonAtomic } from './fsutil.js';
4
5
  // We store sessions (DeepSeek chats) in a separate folder so they aren't lost
5
6
  // after a process restart. Each session is a separate JSON file
6
7
  // <id>.json in ~/.zames/.sessions. last.json points to the last
@@ -8,11 +9,6 @@ import os from 'os';
8
9
  const SESSIONS_DIR = path.join(os.homedir(), '.zames', '.sessions');
9
10
  const INDEX_FILE = path.join(SESSIONS_DIR, 'last.json');
10
11
  export const SESSIONS_FORMAT_VERSION = 1;
11
- function atomicWriteJson(file, value) {
12
- const tmp = file + '.tmp-' + process.pid;
13
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf-8');
14
- fs.renameSync(tmp, file);
15
- }
16
12
  function ensureDir() {
17
13
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
18
14
  }
@@ -46,7 +42,7 @@ export function saveSession({ id, title = '', workdir = '', todos, }) {
46
42
  const nextTodos = todos !== undefined ? todos : prev.todos;
47
43
  if (nextTodos !== undefined)
48
44
  data.todos = nextTodos;
49
- atomicWriteJson(file, data);
45
+ writeJsonAtomic(file, data);
50
46
  writeIndex({ id, workdir: data.workdir });
51
47
  return data;
52
48
  }
@@ -71,7 +67,7 @@ function writeIndex({ id, workdir }) {
71
67
  index.byWorkdir[key] = id;
72
68
  index.last = id;
73
69
  index.updatedAt = new Date().toISOString();
74
- atomicWriteJson(INDEX_FILE, index);
70
+ writeJsonAtomic(INDEX_FILE, index);
75
71
  }
76
72
  catch { }
77
73
  }
@@ -154,7 +150,7 @@ export function saveHistory(history) {
154
150
  try {
155
151
  ensureDir();
156
152
  const trimmed = history.slice(-HISTORY_LIMIT);
157
- atomicWriteJson(HISTORY_FILE, trimmed);
153
+ writeJsonAtomic(HISTORY_FILE, trimmed);
158
154
  }
159
155
  catch { }
160
156
  }
package/dist/spinner.js CHANGED
@@ -27,6 +27,9 @@ export function renderDots(n) {
27
27
  return theme.brown(d + DOTS_PAD.slice(d.length));
28
28
  }
29
29
  export function createSpinner(locale = 'ru') {
30
+ // Set by the caller (index.ts) to the same todos summary the LineEditor
31
+ // shows, so the non-TTY path has parity.
32
+ let onTasksQuery = null;
30
33
  let spinner = null;
31
34
  let dotTimer = null;
32
35
  let dotPhase = 0;
@@ -37,6 +40,10 @@ export function createSpinner(locale = 'ru') {
37
40
  // zero every second and look frozen on a single dot.
38
41
  let animBase = '';
39
42
  let animating = false;
43
+ // When the current animated phase began, so a long reasoning turn can show
44
+ // "12s" / "2m 05s" instead of looking identical to a hung one. Parity with
45
+ // the LineEditor status line (src/input.ts).
46
+ let animStart = 0;
40
47
  const start = (text) => {
41
48
  if (!spinner)
42
49
  spinner = ora(text).start();
@@ -59,9 +66,34 @@ export function createSpinner(locale = 'ru') {
59
66
  // "running" dot sequence. Shared by the thinking spinner and the send-pause
60
67
  // indicator, so the pause is animated too (before, it was a static line and
61
68
  // the dots did not move — the operator saw a frozen spinner).
69
+ // Elapsed label of the current animated phase ("0s", "45s", "2m 05s").
70
+ // Empty when nothing is animating. Localized units come from the catalog.
71
+ const elapsedLabel = () => {
72
+ if (!animating || !animStart)
73
+ return '';
74
+ const sec = Math.floor((Date.now() - animStart) / 1000);
75
+ const m = Math.floor(sec / 60);
76
+ const s = sec % 60;
77
+ const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
78
+ return theme.dim(' · ' + text);
79
+ };
80
+ // Task-list badge, refreshed from onTasksQuery on every animation tick.
81
+ // Mirrors the LineEditor so the non-TTY spinner shows the same "tasks: 2/5".
82
+ const tasksBadge = () => {
83
+ if (!onTasksQuery)
84
+ return '';
85
+ try {
86
+ const s = onTasksQuery();
87
+ return s ? theme.system(s) + ' ' : '';
88
+ }
89
+ catch {
90
+ return '';
91
+ }
92
+ };
62
93
  const startAnimated = (baseText) => {
63
94
  animBase = theme.brown(stripEllipsis(baseText));
64
- start(animBase + renderDots(0) + HINT);
95
+ animStart = Date.now();
96
+ start(animBase + renderDots(0) + elapsedLabel() + tasksBadge() + HINT);
65
97
  dotPhase = 0;
66
98
  if (dotTimer)
67
99
  clearInterval(dotTimer);
@@ -69,7 +101,8 @@ export function createSpinner(locale = 'ru') {
69
101
  if (!spinner)
70
102
  return;
71
103
  dotPhase = (dotPhase + 1) % DOTS.length;
72
- spinner.text = animBase + renderDots(dotPhase) + HINT;
104
+ spinner.text =
105
+ animBase + renderDots(dotPhase) + elapsedLabel() + tasksBadge() + HINT;
73
106
  }, 400);
74
107
  animating = true;
75
108
  if (dotTimer.unref)
@@ -93,6 +126,12 @@ export function createSpinner(locale = 'ru') {
93
126
  spinner.text = theme.prompt('✎ ') + pending + HINT;
94
127
  };
95
128
  return {
129
+ get onTasksQuery() {
130
+ return onTasksQuery;
131
+ },
132
+ set onTasksQuery(fn) {
133
+ onTasksQuery = fn;
134
+ },
96
135
  thinking: () => {
97
136
  if (pending) {
98
137
  showPending();
@@ -112,7 +151,7 @@ export function createSpinner(locale = 'ru') {
112
151
  // so the countdown refreshes without resetting the dots.
113
152
  if (animating && dotTimer && spinner) {
114
153
  animBase = theme.brown(stripEllipsis(label));
115
- spinner.text = animBase + renderDots(dotPhase) + HINT;
154
+ spinner.text = animBase + renderDots(dotPhase) + elapsedLabel() + HINT;
116
155
  return;
117
156
  }
118
157
  startAnimated(label);
@@ -128,6 +128,19 @@ You have access to the following tools:
128
128
 
129
129
  ${toolDescriptions}
130
130
  ${gitSection}${attachSection}${contextSection}
131
+ ## Choosing the right tool
132
+
133
+ Prefer the most specific tool; a wrong choice wastes a turn:
134
+
135
+ - Several small edits in ONE file -> MultiEdit (applied atomically), not
136
+ several Edit calls.
137
+ - Changes across SEVERAL files -> ApplyPatch (V4A), not a series of writes.
138
+ - One exact replacement -> Edit. Creating or overwriting a whole file -> Write.
139
+ - Searching file CONTENT -> Grep; finding files by NAME -> Glob; listing a
140
+ directory -> LS.
141
+ - Git operations -> the Git* tools, not Bash.
142
+ - Reading a file whose path you already know -> Read, not Bash cat.
143
+
131
144
  ## MCP tools
132
145
 
133
146
  Some tools are provided by external MCP servers and their names look