zames_pro 2.54.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,21 +11,26 @@ 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, renderPermissions, 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';
25
25
  import { closeWeb } from './web.js';
26
+ import { assertCommandInsideRoot, runShell } from './shell.js';
26
27
  import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
27
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
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 = '';
29
34
  // Floor for a /loop interval. A loop faster than the send throttle (15s)
30
35
  // cannot do anything useful and would just hammer the rate limit, so we refuse
31
36
  // it. It does NOT touch the throttle itself.
@@ -53,7 +58,7 @@ function getPositional() {
53
58
  const positional = [];
54
59
  for (let i = 0; i < args.length; i++) {
55
60
  const a = args[i];
56
- if (['--dir', '--task', '--max-iter', '--project', '--chat'].includes(a)) {
61
+ if (['--dir', '--task', '--max-iter', '--chat', '--output-format'].includes(a)) {
57
62
  i++;
58
63
  continue;
59
64
  }
@@ -87,7 +92,6 @@ const headless = hasFlag('--headed')
87
92
  ? false
88
93
  : hasFlag('--headless') || config.headless;
89
94
  const debug = hasFlag('--debug') || config.debug;
90
- const calibrate = hasFlag('--calibrate');
91
95
  const maxIterArg = getArg('--max-iter', null);
92
96
  const maxIter = maxIterArg !== null ? Number(maxIterArg) : config.maxIterations;
93
97
  const positional = getPositional();
@@ -102,6 +106,23 @@ const resendPrompt = hasFlag('--resend-prompt');
102
106
  // --new-chat: kept for compatibility — this is the default behavior anyway.
103
107
  const newChatFlag = hasFlag('--new-chat');
104
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
+ }
105
126
  // ---------- session persistence ----------
106
127
  // We store sessions (DeepSeek chats) in ~/.zames/.sessions so they survive
107
128
  // a restart and are available for explicit restore (--resume-last,
@@ -167,10 +188,10 @@ const RELOADABLE = [
167
188
  'self-review',
168
189
  'diff',
169
190
  'undo',
170
- 'confirm',
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')}
@@ -309,40 +331,63 @@ ${theme.bold(t('help.while_working'))}
309
331
  ${t('help.key.backslash')}
310
332
  ${t('help.key.attach')}
311
333
  ${t('help.key.esc')}
334
+ ${t('help.key.bang')}
312
335
 
313
336
  ${theme.bold(t('help.commands'))}
337
+
338
+ ${theme.bold(t('help.sec.session'))}
314
339
  ${t('help.cmd.new')}
315
340
  ${t('help.cmd.sessions')}
316
- ${t('help.cmd.resume_id')}
317
341
  ${t('help.cmd.chats')}
318
342
  ${t('help.cmd.resume')}
343
+ ${t('help.cmd.resume_id')}
319
344
  ${t('help.cmd.last')}
345
+ ${t('help.cmd.retry')}
346
+ ${t('help.cmd.rename')}
347
+
348
+ ${theme.bold(t('help.sec.workspace'))}
320
349
  ${t('help.cmd.chat')}
321
350
  ${t('help.cmd.cd')}
322
351
  ${t('help.cmd.pwd')}
323
352
  ${t('help.cmd.status')}
324
353
  ${t('help.cmd.reload')}
325
- ${t('help.cmd.undo')}
326
- ${t('help.cmd.undo_list')}
327
- ${t('help.cmd.transcript')}
354
+
355
+ ${theme.bold(t('help.sec.git'))}
328
356
  ${t('help.cmd.diff')}
329
- ${t('help.cmd.cost')}
330
- ${t('help.cmd.export')}
331
- ${t('help.cmd.doctor')}
332
- ${t('help.cmd.permissions')}
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')}
370
+
371
+ ${theme.bold(t('help.sec.context'))}
372
+ ${t('help.cmd.context')}
373
+ ${t('help.cmd.skills')}
374
+ ${t('help.cmd.memory')}
375
+ ${t('help.cmd.remember')}
376
+ ${t('help.cmd.init')}
377
+ ${t('help.cmd.mcp')}
344
378
  ${t('help.cmd.config')}
345
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')}
346
391
  ${t('help.cmd.debug_dom')}
347
392
  ${t('help.cmd.help')}
348
393
  ${t('help.cmd.exit')}
@@ -384,6 +429,10 @@ const SLASH_COMMANDS = [
384
429
  { name: '/resume', key: 'help.cmd.resume' },
385
430
  { name: '/resume-id', key: 'help.cmd.resume_id' },
386
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' },
387
436
  { name: '/chat', key: 'help.cmd.chat' },
388
437
  { name: '/cd', key: 'help.cmd.cd' },
389
438
  { name: '/pwd', key: 'help.cmd.pwd' },
@@ -393,10 +442,10 @@ const SLASH_COMMANDS = [
393
442
  { name: '/undo-list', key: 'help.cmd.undo_list' },
394
443
  { name: '/transcript', key: 'help.cmd.transcript' },
395
444
  { name: '/diff', key: 'help.cmd.diff' },
445
+ { name: '/diffstat', key: 'help.cmd.diffstat' },
396
446
  { name: '/cost', key: 'help.cmd.cost' },
397
447
  { name: '/export', key: 'help.cmd.export' },
398
448
  { name: '/doctor', key: 'help.cmd.doctor' },
399
- { name: '/permissions', key: 'help.cmd.permissions' },
400
449
  { name: '/add-dir', key: 'help.cmd.add_dir' },
401
450
  { name: '/review', key: 'help.cmd.review' },
402
451
  { name: '/goal', key: 'help.cmd.goal' },
@@ -405,12 +454,14 @@ const SLASH_COMMANDS = [
405
454
  { name: '/jobs', key: 'help.cmd.jobs' },
406
455
  { name: '/thinking', key: 'help.cmd.thinking' },
407
456
  { name: '/web', key: 'help.cmd.web' },
457
+ { name: '/plan', key: 'help.cmd.plan' },
408
458
  { name: '/compact', key: 'help.cmd.compact' },
409
459
  { name: '/queue', key: 'help.cmd.queue' },
410
460
  { name: '/tasks', key: 'help.cmd.tasks' },
411
461
  { name: '/config', key: 'help.cmd.config' },
412
462
  { name: '/skills', key: 'help.cmd.skills' },
413
463
  { name: '/memory', key: 'help.cmd.memory' },
464
+ { name: '/remember', key: 'help.cmd.remember' },
414
465
  { name: '/init', key: 'help.cmd.init' },
415
466
  { name: '/mcp', key: 'help.cmd.mcp' },
416
467
  { name: '/debug-dom', key: 'help.cmd.debug_dom' },
@@ -1003,7 +1054,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
1003
1054
  }
1004
1055
  // ---------- task runner ----------
1005
1056
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
1006
- 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;
1007
1058
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
1008
1059
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
1009
1060
  // mode (pipes) — a regular spinner + watchInput.
@@ -1011,6 +1062,12 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1011
1062
  browser._stopped = false;
1012
1063
  browser._abort = false;
1013
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
+ }
1014
1071
  // Mark the editor busy for the WHOLE task, not only for /init and /self-fix.
1015
1072
  // Esc / Ctrl+C abort the current generation only while busy; without this a
1016
1073
  // long-running tool (Bash, npm, MCP) could not be interrupted — Esc did
@@ -1084,7 +1141,14 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1084
1141
  ui.toolCall(name, toolArgs);
1085
1142
  },
1086
1143
  onToolResult: (result) => ui.toolResult(result),
1087
- 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
+ },
1088
1152
  onWarning: (msg) => ui.warning(msg),
1089
1153
  onChatReady,
1090
1154
  debugLog: debug,
@@ -1216,9 +1280,6 @@ async function main() {
1216
1280
  printHelp();
1217
1281
  return;
1218
1282
  }
1219
- if (calibrate) {
1220
- console.log(theme.warn(t('calibrate')));
1221
- }
1222
1283
  await cleanTmpDir();
1223
1284
  let currentWorkdir;
1224
1285
  let sandboxRoot;
@@ -1237,6 +1298,9 @@ async function main() {
1237
1298
  dir: config.transcript.dir,
1238
1299
  enabled: config.transcript.enabled,
1239
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,
1240
1304
  });
1241
1305
  if (transcript.file) {
1242
1306
  console.log(theme.system(t('msg.transcript', { v: transcript.file })));
@@ -1258,6 +1322,8 @@ async function main() {
1258
1322
  if (srv.error)
1259
1323
  console.error(theme.warn(t('mcp.server_error', { name: srv.name, error: srv.error })));
1260
1324
  }
1325
+ for (const w of st.warnings)
1326
+ console.error(theme.warn(w));
1261
1327
  }
1262
1328
  catch (e) {
1263
1329
  console.error(theme.warn(t('mcp.load_failed', { v: e.message })));
@@ -1298,7 +1364,10 @@ async function main() {
1298
1364
  }
1299
1365
  // One-shot mode
1300
1366
  if (task) {
1301
- const tools = mod.createTools(currentWorkdir, { undo });
1367
+ const tools = mod.createTools(currentWorkdir, {
1368
+ undo,
1369
+ readOnly: planMode,
1370
+ });
1302
1371
  if (mcpPool)
1303
1372
  tools.push(...mcpPool.tools);
1304
1373
  let freshChat = true;
@@ -1361,6 +1430,8 @@ async function main() {
1361
1430
  const promptOnResume = () => resendPrompt || config.browser.resendPromptOnResume === true;
1362
1431
  let lastChats = [];
1363
1432
  let currentChatId = null;
1433
+ // The last task the operator sent (used by /retry). Plain text only.
1434
+ let lastTaskText = '';
1364
1435
  let running = true;
1365
1436
  // The agent's TodoWrite checklist, shared with the tool factory and
1366
1437
  // persisted in the session JSON. A store (not module state) so /reload does
@@ -2153,6 +2224,36 @@ async function main() {
2153
2224
  const lower = trimmed.toLowerCase();
2154
2225
  if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2155
2226
  break;
2227
+ // `!command` — run a shell command directly, without asking the model
2228
+ // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
2229
+ // applies, so a direct command cannot leave the project either.
2230
+ if (trimmed.startsWith('!')) {
2231
+ const cmd = trimmed.slice(1).trim();
2232
+ if (!cmd) {
2233
+ console.error(theme.warn(t('shell.empty')));
2234
+ continue;
2235
+ }
2236
+ try {
2237
+ assertCommandInsideRoot(currentWorkdir, cmd);
2238
+ }
2239
+ catch (e) {
2240
+ console.error(theme.error(e.message));
2241
+ continue;
2242
+ }
2243
+ transcript.log('shell_command', { command: cmd });
2244
+ if (editor)
2245
+ editor.lock(t('shell.running'));
2246
+ try {
2247
+ const out = await runShell(currentWorkdir, cmd);
2248
+ console.log(theme.system(out));
2249
+ transcript.log('shell_result', { command: cmd, result: out });
2250
+ }
2251
+ finally {
2252
+ if (editor)
2253
+ editor.unlock();
2254
+ }
2255
+ continue;
2256
+ }
2156
2257
  if (lower === '/help' ||
2157
2258
  lower === 'help' ||
2158
2259
  lower.startsWith('/help ') ||
@@ -2711,6 +2812,28 @@ async function main() {
2711
2812
  continue;
2712
2813
  }
2713
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
+ }
2714
2837
  if (lower === '/mcp') {
2715
2838
  if (!mcpPool) {
2716
2839
  console.log(theme.dim(t('mcp.none')));
@@ -2733,6 +2856,8 @@ async function main() {
2733
2856
  theme.dim('(' + String(srv.tools.length) + ')'));
2734
2857
  }
2735
2858
  }
2859
+ for (const w of st.warnings)
2860
+ console.log(theme.warn(' ' + w));
2736
2861
  continue;
2737
2862
  }
2738
2863
  if (lower === '/skills') {
@@ -2771,6 +2896,34 @@ async function main() {
2771
2896
  show(t('memory.memory'), ctx.memory);
2772
2897
  continue;
2773
2898
  }
2899
+ // `/remember <text>` — append a durable note to the project MEMORY.md.
2900
+ // Claude Code has the same idea (# shortcut): a fact worth keeping across
2901
+ // sessions is written to disk instead of living only in this chat. We
2902
+ // write to the workdir's own MEMORY.md (the deepest file the loader reads)
2903
+ // and create it when missing.
2904
+ if (lower === '/remember' || lower.startsWith('/remember ')) {
2905
+ const note = trimmed.slice('/remember'.length).trim();
2906
+ if (!note) {
2907
+ console.error(theme.warn(t('remember.usage')));
2908
+ continue;
2909
+ }
2910
+ const memFile = path.join(currentWorkdir, 'MEMORY.md');
2911
+ const stamp = new Date().toISOString().slice(0, 10);
2912
+ let body;
2913
+ try {
2914
+ body = await fs.readFile(memFile, 'utf-8');
2915
+ }
2916
+ catch {
2917
+ body = '# MEMORY\n\nDurable notes accumulated across sessions.\n';
2918
+ }
2919
+ if (body.length && !body.endsWith(NL))
2920
+ body += NL;
2921
+ body += NL + '- (' + stamp + ') ' + note + NL;
2922
+ await fs.writeFile(memFile, body, 'utf-8');
2923
+ transcript.log('remember', { note, file: memFile });
2924
+ console.log(theme.assistant(t('remember.saved', { v: memFile })));
2925
+ continue;
2926
+ }
2774
2927
  if (lower === '/init' || lower.startsWith('/init ')) {
2775
2928
  const force = lower.includes('--force');
2776
2929
  const target = path.join(currentWorkdir, 'AGENTS.md');
@@ -2936,6 +3089,98 @@ async function main() {
2936
3089
  console.log(theme.system(transcript.file || t('common.off')));
2937
3090
  continue;
2938
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
+ }
2939
3184
  if (lower === '/diff' || lower.startsWith('/diff ')) {
2940
3185
  const staged = lower.indexOf('--staged') !== -1;
2941
3186
  const { runGit } = await import('./gitTools.js');
@@ -2948,6 +3193,17 @@ async function main() {
2948
3193
  console.log(theme.system(formatDiff(out, { maxLines: 400 }, t)));
2949
3194
  continue;
2950
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
+ }
2951
3207
  if (lower === '/cost' || lower === '/usage') {
2952
3208
  let stats = summarizeTranscript([]);
2953
3209
  if (transcript.file) {
@@ -3037,15 +3293,6 @@ async function main() {
3037
3293
  }, t)));
3038
3294
  continue;
3039
3295
  }
3040
- if (lower === '/permissions' || lower === '/allowed-tools') {
3041
- console.log(theme.system(renderPermissions({
3042
- write: config.confirmation.write,
3043
- edit: config.confirmation.edit,
3044
- bash: config.confirmation.bash,
3045
- alwaysConfirm: config.confirmation.alwaysConfirm,
3046
- }, t)));
3047
- continue;
3048
- }
3049
3296
  if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
3050
3297
  const arg = trimmed.slice('/add-dir'.length).trim();
3051
3298
  const res = resolveExtraDir(arg, currentWorkdir, t);
@@ -3128,7 +3375,15 @@ async function main() {
3128
3375
  transcript.log('undo', { path: result.record.originalPath });
3129
3376
  }
3130
3377
  else {
3131
- 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 })));
3132
3387
  }
3133
3388
  continue;
3134
3389
  }
@@ -3212,6 +3467,7 @@ async function main() {
3212
3467
  }
3213
3468
  // ---- Regular task (including in review mode) ----
3214
3469
  const taskText = expandedTask !== null ? expandedTask : trimmed;
3470
+ lastTaskText = taskText;
3215
3471
  // Dev mode: pick up fresh logic modules before the task.
3216
3472
  await autoReload();
3217
3473
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3221,7 +3477,11 @@ async function main() {
3221
3477
  if (editor)
3222
3478
  editor.setCommands(buildSlashCommands());
3223
3479
  transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
3224
- const tools = mod.createTools(currentWorkdir, { undo, todos: todoStore });
3480
+ const tools = mod.createTools(currentWorkdir, {
3481
+ undo,
3482
+ todos: todoStore,
3483
+ readOnly: planMode,
3484
+ });
3225
3485
  if (mcpPool)
3226
3486
  tools.push(...mcpPool.tools);
3227
3487
  if (editor)
@@ -3276,6 +3536,7 @@ async function main() {
3276
3536
  contextLimit: config.ui.contextLimit,
3277
3537
  getTokenUsage: () => browser.getLastTokenUsage(),
3278
3538
  goal: sessionGoal,
3539
+ todosQuery: () => todosSummary(),
3279
3540
  }, inputAttachments);
3280
3541
  }
3281
3542
  finally {
package/dist/input.js CHANGED
@@ -1298,6 +1298,11 @@ export class LineEditor {
1298
1298
  // dots) and keep it running until toolResult()/assistant()/stop(). Esc now
1299
1299
  // really aborts the tool (the Bash child process is killed via the
1300
1300
  // AbortSignal), so the "Esc — стоп" hint is shown.
1301
+ //
1302
+ // The tool status is NOT a send phase: clear the lifecycle prefix first,
1303
+ // otherwise the previous answer's "✓ done" sticks and the status reads
1304
+ // "✓ done: running Bash" on one line.
1305
+ this._sendState = '';
1301
1306
  this._startAnimated(translate(this.locale)('spinner.running_tool', { name }));
1302
1307
  }
1303
1308
  toolResult(result) {
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)