zames_pro 2.13.0 → 2.15.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.
@@ -0,0 +1,352 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ // Extra tools that bring zames closer to Claude Code / Codex CLI:
4
+ // * LS - list a directory (Claude Code has LS; Codex has list_dir)
5
+ // * MultiEdit - several edits to ONE file applied atomically (Claude Code)
6
+ // * ApplyPatch - multi-file V4A-style patch (Codex apply_patch)
7
+ // * TodoWrite - session task checklist (Claude Code TodoWrite)
8
+ //
9
+ // The core file tools (Read/Write/Edit/Bash/Glob/Grep) live in tools.ts.
10
+ // These are additive: they do not change the existing tool behaviour.
11
+ const NL = String.fromCharCode(10);
12
+ function decodeContent(content, contentBase64) {
13
+ if (typeof contentBase64 === 'string' && contentBase64.length) {
14
+ return Buffer.from(contentBase64, 'base64').toString('utf-8');
15
+ }
16
+ return String(content ?? '');
17
+ }
18
+ // The todo list is per-process session state. It is deliberately NOT
19
+ // persisted: a fresh run starts with an empty list, like Claude Code's
20
+ // session checklist.
21
+ const todoList = [];
22
+ export function getTodos() {
23
+ return todoList.map((t) => ({ ...t }));
24
+ }
25
+ export function resetTodos() {
26
+ todoList.length = 0;
27
+ }
28
+ export function renderTodos(items) {
29
+ if (!items.length)
30
+ return 'Todo list is empty.';
31
+ const mark = (s) => s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]';
32
+ const lines = items.map((t, i) => `${i + 1}. ${mark(t.status)} ${t.content}`);
33
+ const done = items.filter((t) => t.status === 'completed').length;
34
+ return `Todo list (${done}/${items.length} done):${NL}${lines.join(NL)}`;
35
+ }
36
+ export function normalizeTodos(list) {
37
+ const items = [];
38
+ for (const raw of list) {
39
+ const it = (raw || {});
40
+ const content = String(it.content ?? it.text ?? '').trim();
41
+ if (!content)
42
+ continue;
43
+ const s = String(it.status ?? 'pending');
44
+ const status = s === 'completed' || s === 'done'
45
+ ? 'completed'
46
+ : s === 'in_progress' || s === 'active'
47
+ ? 'in_progress'
48
+ : 'pending';
49
+ items.push({ content, status });
50
+ }
51
+ return items;
52
+ }
53
+ /** Parse a V4A-style patch body into operations. Exported for tests. */
54
+ export function parsePatch(text) {
55
+ const raw = String(text ?? '').replace(/\r\n/g, NL);
56
+ if (!/^\s*\*\*\*\s*Begin Patch/m.test(raw)) {
57
+ return { ops: [], error: 'Patch must start with "*** Begin Patch".' };
58
+ }
59
+ if (!/\*\*\*\s*End Patch/.test(raw)) {
60
+ return { ops: [], error: 'Patch must end with "*** End Patch".' };
61
+ }
62
+ const beginIdx = raw.indexOf('*** Begin Patch');
63
+ const endIdx = raw.lastIndexOf('*** End Patch');
64
+ const beginLineEnd = raw.indexOf(NL, beginIdx);
65
+ const body = raw.slice(beginLineEnd === -1 ? beginIdx : beginLineEnd + 1, endIdx);
66
+ const ops = [];
67
+ let current = null;
68
+ for (const line of body.split(NL)) {
69
+ const header = line.match(/^\*\*\*\s*(Add|Update|Delete) File:\s*(.+?)\s*$/);
70
+ if (header) {
71
+ const kind = header[1].toLowerCase();
72
+ const file = header[2].trim();
73
+ if (!file)
74
+ return { ops: [], error: 'Patch header has an empty path.' };
75
+ if (path.isAbsolute(file) || /(^|[\\/])\.\.([\\/]|$)/.test(file)) {
76
+ return { ops: [], error: 'Patch path must stay inside the project: ' + file };
77
+ }
78
+ current = { op: kind, file, lines: [] };
79
+ ops.push(current);
80
+ continue;
81
+ }
82
+ if (current)
83
+ current.lines.push(line);
84
+ }
85
+ if (!ops.length)
86
+ return { ops: [], error: 'Patch contains no file operations.' };
87
+ return { ops };
88
+ }
89
+ /**
90
+ * Apply an update hunk to existing content using context matching.
91
+ * The hunk is a list of lines prefixed with ' ' (context), '-' (remove),
92
+ * '+' (add). We locate the context block by exact match, then by ignoring
93
+ * trailing whitespace, then by ignoring all whitespace (like Codex).
94
+ */
95
+ export function applyUpdateHunk(content, hunkLines) {
96
+ const hasCRLF = content.includes('\r\n');
97
+ const fileLines = content.split(/\r?\n/);
98
+ const oldLines = [];
99
+ const newLines = [];
100
+ for (const l of hunkLines) {
101
+ if (l.startsWith('@@'))
102
+ continue;
103
+ if (l.startsWith('+')) {
104
+ newLines.push(l.slice(1));
105
+ }
106
+ else if (l.startsWith('-')) {
107
+ oldLines.push(l.slice(1));
108
+ }
109
+ else if (l.startsWith(' ')) {
110
+ oldLines.push(l.slice(1));
111
+ newLines.push(l.slice(1));
112
+ }
113
+ else if (l === '') {
114
+ oldLines.push('');
115
+ newLines.push('');
116
+ }
117
+ else {
118
+ oldLines.push(l);
119
+ newLines.push(l);
120
+ }
121
+ }
122
+ if (!oldLines.length) {
123
+ return { ok: false, error: 'Hunk has no context or removed lines to anchor on.' };
124
+ }
125
+ const norm = (s, mode) => {
126
+ if (mode === 'exact')
127
+ return s;
128
+ if (mode === 'trim')
129
+ return s.replace(/[ \t]+$/, '');
130
+ return s.replace(/\s+/g, '');
131
+ };
132
+ for (const mode of ['exact', 'trim', 'ws']) {
133
+ const anchor = oldLines.map((l) => norm(l, mode));
134
+ for (let i = 0; i + anchor.length <= fileLines.length; i++) {
135
+ let match = true;
136
+ for (let j = 0; j < anchor.length; j++) {
137
+ if (norm(fileLines[i + j], mode) !== anchor[j]) {
138
+ match = false;
139
+ break;
140
+ }
141
+ }
142
+ if (!match)
143
+ continue;
144
+ const before = fileLines.slice(0, i);
145
+ const after = fileLines.slice(i + anchor.length);
146
+ const joined = [...before, ...newLines, ...after].join(hasCRLF ? '\r\n' : NL);
147
+ return { ok: true, content: joined };
148
+ }
149
+ }
150
+ return {
151
+ ok: false,
152
+ error: 'Context not found in the file (hunk did not match): ' +
153
+ oldLines[0].slice(0, 60),
154
+ };
155
+ }
156
+ // ---------- factory ----------
157
+ export function createExtraTools(workdir, { undo } = {}) {
158
+ const root = path.resolve(workdir);
159
+ const safe = (p) => {
160
+ const resolved = path.resolve(root, p);
161
+ const rel = path.relative(root, resolved);
162
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
163
+ throw new Error('Access outside the working directory is forbidden: ' + p);
164
+ }
165
+ return resolved;
166
+ };
167
+ // Guard against missing/placeholder required args: a missing `path` becomes
168
+ // String(undefined) === "undefined" and would create a file literally named
169
+ // "undefined" in the working directory.
170
+ const req = (v, name) => {
171
+ if (v === undefined || v === null) {
172
+ throw new Error(`Missing required argument: ${name}`);
173
+ }
174
+ const s = String(v);
175
+ if (s === '' || s === 'undefined' || s === 'null') {
176
+ throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
177
+ }
178
+ return s;
179
+ };
180
+ return [
181
+ {
182
+ name: 'LS',
183
+ description: 'List the contents of a directory (files and subfolders). ' +
184
+ 'path defaults to the working directory. Folders are marked with a slash.',
185
+ parameters: { path: 'string?', ignore: 'string?' },
186
+ fn: async ({ path: p, ignore }) => {
187
+ const target = p ? safe(req(p, 'path')) : root;
188
+ const stat = await fs.stat(target).catch(() => null);
189
+ if (!stat)
190
+ throw new Error('No such directory: ' + p);
191
+ if (!stat.isDirectory())
192
+ throw new Error('Not a directory: ' + p);
193
+ const ignoreList = (ignore ? String(ignore) : '')
194
+ .split(',')
195
+ .map((s) => s.trim())
196
+ .filter(Boolean);
197
+ const entries = await fs.readdir(target, { withFileTypes: true });
198
+ const rows = [];
199
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
200
+ if (ignoreList.includes(e.name))
201
+ continue;
202
+ if (e.name === 'node_modules' || e.name === '.git') {
203
+ rows.push(e.name + '/ (skipped)');
204
+ continue;
205
+ }
206
+ if (e.isDirectory()) {
207
+ rows.push(e.name + '/');
208
+ }
209
+ else {
210
+ const full = path.join(target, e.name);
211
+ const st = await fs.stat(full).catch(() => null);
212
+ rows.push(e.name + (st ? ` (${st.size} bytes)` : ''));
213
+ }
214
+ }
215
+ return rows.length ? rows.join(NL) : '(empty)';
216
+ },
217
+ },
218
+ {
219
+ name: 'MultiEdit',
220
+ description: 'Several targeted edits to ONE file in a single call. edits is an array of ' +
221
+ '{old_string, new_string, replace_all?}. Edits are applied ' +
222
+ 'in order; if any is not found — none are applied. ' +
223
+ 'For a single edit use Edit.',
224
+ parameters: {
225
+ path: 'string',
226
+ edits: 'array',
227
+ edits_base64: 'string?',
228
+ },
229
+ fn: async ({ path: p, edits, edits_base64 }) => {
230
+ const file = safe(req(p, 'path'));
231
+ let list;
232
+ if (typeof edits_base64 === 'string' && edits_base64.length) {
233
+ list = JSON.parse(Buffer.from(edits_base64, 'base64').toString('utf-8'));
234
+ }
235
+ else if (Array.isArray(edits)) {
236
+ list = edits;
237
+ }
238
+ else {
239
+ throw new Error('edits must be an array of edits.');
240
+ }
241
+ if (!list.length)
242
+ throw new Error('edits is empty — nothing to apply.');
243
+ let content = await fs.readFile(file, 'utf-8');
244
+ for (let i = 0; i < list.length; i++) {
245
+ const e = list[i];
246
+ const oldStr = String(e.old_string ?? '');
247
+ const newStr = String(e.new_string ?? '');
248
+ if (oldStr === '') {
249
+ throw new Error(`edits[${i}]: old_string is empty.`);
250
+ }
251
+ const occurrences = content.split(oldStr).length - 1;
252
+ if (occurrences === 0) {
253
+ throw new Error(`edits[${i}]: string not found: "${oldStr.slice(0, 60)}..."`);
254
+ }
255
+ if (occurrences > 1 && !e.replace_all) {
256
+ throw new Error(`edits[${i}]: string occurs ${occurrences} times. ` +
257
+ 'Make old_string more specific or pass replace_all=true.');
258
+ }
259
+ content = e.replace_all
260
+ ? content.split(oldStr).join(newStr)
261
+ : content.replace(oldStr, newStr);
262
+ }
263
+ if (undo)
264
+ await undo.backup(file);
265
+ await fs.writeFile(file, content, 'utf-8');
266
+ return `Edited (${list.length} edits): ${p}`;
267
+ },
268
+ },
269
+ {
270
+ name: 'TodoWrite',
271
+ description: 'Create/update the session task list. Accepts todos — an array of ' +
272
+ '{content, status}, where status: pending | in_progress | completed. ' +
273
+ 'Helps track multi-step tasks. Replaces the whole list.',
274
+ parameters: { todos: 'array', todos_base64: 'string?' },
275
+ fn: async ({ todos, todos_base64 }) => {
276
+ let list;
277
+ if (typeof todos_base64 === 'string' && todos_base64.length) {
278
+ list = JSON.parse(Buffer.from(todos_base64, 'base64').toString('utf-8'));
279
+ }
280
+ else if (Array.isArray(todos)) {
281
+ list = todos;
282
+ }
283
+ else {
284
+ throw new Error('todos must be an array.');
285
+ }
286
+ const items = normalizeTodos(list);
287
+ todoList.length = 0;
288
+ todoList.push(...items);
289
+ return renderTodos(todoList);
290
+ },
291
+ },
292
+ {
293
+ name: 'ApplyPatch',
294
+ description: 'Apply a patch to several files in a single call (Codex format). ' +
295
+ 'Body: "*** Begin Patch", operations "*** Add File: <path>" (lines with +), ' +
296
+ '"*** Update File: <path>" (context with a space, removals with -, additions ' +
297
+ 'with +, @@ anchors optional), "*** Delete File: <path>", then ' +
298
+ '"*** End Patch". Paths must be relative and inside the project.',
299
+ parameters: { patch: 'string?', patch_base64: 'string?' },
300
+ fn: async ({ patch, patch_base64 }) => {
301
+ const text = decodeContent(patch, patch_base64);
302
+ const { ops, error } = parsePatch(text);
303
+ if (error)
304
+ throw new Error(error);
305
+ // Stage every change first, then write.
306
+ const writes = [];
307
+ for (const op of ops) {
308
+ const file = safe(op.file);
309
+ if (op.op === 'delete') {
310
+ writes.push({ file, content: null });
311
+ continue;
312
+ }
313
+ if (op.op === 'add') {
314
+ const body = op.lines
315
+ .filter((l) => l.startsWith('+'))
316
+ .map((l) => l.slice(1))
317
+ .join(NL);
318
+ writes.push({ file, content: body + NL });
319
+ continue;
320
+ }
321
+ let current;
322
+ try {
323
+ current = await fs.readFile(file, 'utf-8');
324
+ }
325
+ catch {
326
+ throw new Error('Update File: no such file ' + op.file);
327
+ }
328
+ const res = applyUpdateHunk(current, op.lines);
329
+ if (!res.ok) {
330
+ throw new Error('Update File ' + op.file + ': ' + res.error);
331
+ }
332
+ writes.push({ file, content: res.content });
333
+ }
334
+ const summary = [];
335
+ for (const w of writes) {
336
+ if (undo)
337
+ await undo.backup(w.file);
338
+ if (w.content === null) {
339
+ await fs.unlink(w.file).catch(() => { });
340
+ summary.push('deleted ' + path.relative(root, w.file));
341
+ }
342
+ else {
343
+ await fs.mkdir(path.dirname(w.file), { recursive: true });
344
+ await fs.writeFile(w.file, w.content, 'utf-8');
345
+ summary.push('written ' + path.relative(root, w.file));
346
+ }
347
+ }
348
+ return 'Patch applied:' + NL + summary.join(NL);
349
+ },
350
+ },
351
+ ];
352
+ }
package/dist/gitTools.js CHANGED
@@ -18,16 +18,16 @@ export function runGit(cmd, cwd, timeout = 15_000) {
18
18
  const errStr = (stderr || '').toString();
19
19
  if (!err) {
20
20
  const combined = (out + errStr).trim();
21
- resolve(combined || '(команда выполнена, вывода нет)');
21
+ resolve(combined || '(command produced no output)');
22
22
  return;
23
23
  }
24
24
  const parts = [];
25
25
  if (err.killed)
26
- parts.push(`⏱ Таймаут ${timeout}ms`);
26
+ parts.push(`⏱ Timeout ${timeout}ms`);
27
27
  else if (err.code !== undefined && err.code !== null)
28
28
  parts.push(`Exit code: ${err.code}`);
29
29
  else
30
- parts.push(`Ошибка: ${err.message}`);
30
+ parts.push(`Error: ${err.message}`);
31
31
  if (out.trim())
32
32
  parts.push(out.trim());
33
33
  if (errStr.trim())
@@ -114,7 +114,7 @@ export function createGitTools(workdir) {
114
114
  return [
115
115
  {
116
116
  name: 'GitStatus',
117
- description: 'Показать git status рабочей директории. Возвращает ошибку, если это не git-репозиторий.',
117
+ description: 'Show the git status of the working directory. Returns an error if this is not a git repository.',
118
118
  parameters: {},
119
119
  fn: async () => {
120
120
  const err = await ensureRepo();
@@ -125,7 +125,7 @@ export function createGitTools(workdir) {
125
125
  },
126
126
  {
127
127
  name: 'GitDiff',
128
- description: 'Показать git diff. По умолчанию незастейдженные изменения. Можно ограничить path и указать staged=true.',
128
+ description: 'Show git diff. By default unstaged changes. Can be limited with path and staged=true.',
129
129
  parameters: { path: 'string?', staged: 'boolean?' },
130
130
  fn: async ({ path: p, staged }) => {
131
131
  const err = await ensureRepo();
@@ -141,7 +141,7 @@ export function createGitTools(workdir) {
141
141
  },
142
142
  {
143
143
  name: 'GitLog',
144
- description: 'Показать последние N коммитов (по умолчанию 10).',
144
+ description: 'Show the last N commits (default 10).',
145
145
  parameters: { count: 'number?' },
146
146
  fn: async ({ count }) => {
147
147
  const err = await ensureRepo();
@@ -152,7 +152,7 @@ export function createGitTools(workdir) {
152
152
  },
153
153
  {
154
154
  name: 'GitAdd',
155
- description: 'Добавить файлы в индекс. Если paths не задан — git add -A (всё).',
155
+ description: 'Stage files. If paths is not given — git add -A (everything).',
156
156
  parameters: { paths: 'string?' },
157
157
  fn: async ({ paths }) => {
158
158
  const err = await ensureRepo();
@@ -165,7 +165,7 @@ export function createGitTools(workdir) {
165
165
  },
166
166
  {
167
167
  name: 'GitCommit',
168
- description: 'Закоммитить застейдженное (если ничего не застейджено — сначала git add -A). Не пушит.',
168
+ description: 'Commit staged changes (if nothing is staged — git add -A first). Does not push.',
169
169
  parameters: { message: 'string' },
170
170
  fn: async ({ message }) => {
171
171
  const err = await ensureRepo();
@@ -173,14 +173,14 @@ export function createGitTools(workdir) {
173
173
  return err;
174
174
  const msg = String(message || '');
175
175
  if (!msg.trim()) {
176
- return 'Ошибка: message пустой.';
176
+ return 'Error: message is empty.';
177
177
  }
178
178
  // We stage everything only if the index is empty (as the description promises).
179
179
  const staged = await runGit('git diff --cached --name-only', workdir, 10_000);
180
180
  const hasStaged = !/^\s*/.test(staged) &&
181
- staged.trim() !== '(команда выполнена, вывода нет)';
181
+ staged.trim() !== '(command produced no output)';
182
182
  const addResult = hasStaged
183
- ? '(индекс уже не пуст — add -A пропущен)'
183
+ ? '(index is not empty — add -A skipped)'
184
184
  : await runGit('git add -A', workdir, 20_000);
185
185
  const fs = await import('fs/promises');
186
186
  const path = await import('path');
@@ -198,7 +198,7 @@ export function createGitTools(workdir) {
198
198
  },
199
199
  {
200
200
  name: 'GitPush',
201
- description: 'Запушить ветку в origin. Требует настроенных креденшелов (SSH или Windows Credential Manager).',
201
+ description: 'Push a branch to origin. Requires configured credentials (SSH or Windows Credential Manager).',
202
202
  parameters: { branch: 'string?', setUpstream: 'boolean?' },
203
203
  fn: async ({ branch, setUpstream }) => {
204
204
  const err = await ensureRepo();
@@ -208,7 +208,7 @@ export function createGitTools(workdir) {
208
208
  ? String(branch).trim()
209
209
  : (await runGit('git branch --show-current', workdir, 5000)).trim();
210
210
  if (!b)
211
- return 'Ошибка: не удалось определить ветку для push.';
211
+ return 'Error: could not determine the branch to push.';
212
212
  const flag = setUpstream ? '-u ' : '';
213
213
  const target = quoteArgs(b);
214
214
  return runGit(`git push ${flag}origin ${target}`, workdir, 120_000);
package/dist/i18n.js CHANGED
@@ -78,6 +78,18 @@ const CATALOG = {
78
78
  'help.cmd.undo': { ru: '/undo откатить последнюю запись/правку', en: '/undo revert the last write/edit' },
79
79
  'help.cmd.undo_list': { ru: '/undo-list список того, что можно откатить', en: '/undo-list list revertable changes' },
80
80
  'help.cmd.transcript': { ru: '/transcript путь к файлу транскрипта', en: '/transcript transcript file path' },
81
+ 'help.cmd.diff': { ru: '/diff [--staged] показать git diff', en: '/diff [--staged] show working tree git diff' },
82
+ 'help.cmd.cost': { ru: '/cost статистика сессии', en: '/cost session stats' },
83
+ 'help.cmd.export': { ru: '/export [file] выгрузить сессию в Markdown', en: '/export [file] export the session to Markdown' },
84
+ 'help.cmd.doctor': { ru: '/doctor диагностика установки и конфига', en: '/doctor diagnose install and config' },
85
+ 'help.cmd.permissions': { ru: '/permissions настройки подтверждений', en: '/permissions confirmation settings' },
86
+ 'help.cmd.add_dir': { ru: '/add-dir <path> проверить директорию', en: '/add-dir <path> validate a directory' },
87
+ 'help.cmd.review': { ru: '/review [focus] ревью незакоммиченных изменений', en: '/review [focus] review uncommitted changes' },
88
+ 'diff.not_repo': { ru: 'Не git-репозиторий.', en: 'Not a git repository.' },
89
+ 'export.done': { ru: 'Сессия выгружена: {v}', en: 'Session exported: {v}' },
90
+ 'export.outside': { ru: 'Путь вне рабочей директории.', en: 'Path is outside the working directory.' },
91
+ 'adddir.not_dir': { ru: 'Нет такой директории: {v}', en: 'No such directory: {v}' },
92
+ 'adddir.note': { ru: 'Директория существует: {v}. Песочница фиксируется при запуске.', en: 'Directory exists: {v}. The sandbox is fixed at startup.' },
81
93
  'help.cmd.config': { ru: '/config настройки: показать и изменить', en: '/config settings: view and edit' },
82
94
  'help.cmd.lang': { ru: '/config lang <ru|en> сменить язык интерфейса и агента', en: '/config lang <ru|en> switch UI and agent language' },
83
95
  'help.cmd.debug_dom': { ru: '/debug-dom сохранить HTML страницы (для отладки)', en: '/debug-dom dump page HTML (debug)' },
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
15
15
  import { Transcript } from './transcript.js';
16
16
  import { UndoStore } from './undo.js';
17
17
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
18
+ import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, } from './commands.js';
18
19
  import { closeWeb } from './web.js';
19
20
  import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
20
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -93,6 +94,7 @@ const SRC_EXT = /[.]ts$/.test(new URL(import.meta.url).pathname) ? '.ts' : '.js'
93
94
  const RELOADABLE = [
94
95
  'mcp',
95
96
  'tools',
97
+ 'extraTools',
96
98
  'agent-loop',
97
99
  'system-prompt',
98
100
  'gitTools',
@@ -217,6 +219,13 @@ ${theme.bold(t('help.commands'))}
217
219
  ${t('help.cmd.undo')}
218
220
  ${t('help.cmd.undo_list')}
219
221
  ${t('help.cmd.transcript')}
222
+ ${t('help.cmd.diff')}
223
+ ${t('help.cmd.cost')}
224
+ ${t('help.cmd.export')}
225
+ ${t('help.cmd.doctor')}
226
+ ${t('help.cmd.permissions')}
227
+ ${t('help.cmd.add_dir')}
228
+ ${t('help.cmd.review')}
220
229
  ${t('help.cmd.config')}
221
230
  ${t('help.cmd.lang')}
222
231
  ${t('help.cmd.debug_dom')}
@@ -265,6 +274,13 @@ const SLASH_COMMANDS = [
265
274
  { name: '/undo', key: 'help.cmd.undo' },
266
275
  { name: '/undo-list', key: 'help.cmd.undo_list' },
267
276
  { name: '/transcript', key: 'help.cmd.transcript' },
277
+ { name: '/diff', key: 'help.cmd.diff' },
278
+ { name: '/cost', key: 'help.cmd.cost' },
279
+ { name: '/export', key: 'help.cmd.export' },
280
+ { name: '/doctor', key: 'help.cmd.doctor' },
281
+ { name: '/permissions', key: 'help.cmd.permissions' },
282
+ { name: '/add-dir', key: 'help.cmd.add_dir' },
283
+ { name: '/review', key: 'help.cmd.review' },
268
284
  { name: '/config', key: 'help.cmd.config' },
269
285
  { name: '/skills', key: 'help.cmd.skills' },
270
286
  { name: '/memory', key: 'help.cmd.memory' },
@@ -825,7 +841,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
825
841
  // case no assistant message was shown, and the operator saw the run
826
842
  // just "stop" after a tool call with no explanation. Surface it.
827
843
  if (outcome &&
828
- (outcome.startsWith('Достигнут лимит итераций') ||
844
+ (outcome.startsWith('Iteration limit reached') ||
829
845
  outcome.startsWith('ask() watchdog'))) {
830
846
  ui.warning(outcome);
831
847
  transcript?.log('agent_no_answer', { outcome });
@@ -1985,6 +2001,150 @@ async function main() {
1985
2001
  console.log(theme.system(transcript.file || t('common.off')));
1986
2002
  continue;
1987
2003
  }
2004
+ if (lower === '/diff' || lower.startsWith('/diff ')) {
2005
+ const staged = lower.indexOf("--staged") !== -1;
2006
+ const { runGit } = await import('./gitTools.js');
2007
+ const probe = await runGit('git rev-parse --is-inside-work-tree', currentWorkdir, 5000);
2008
+ if (probe.trim() !== 'true') {
2009
+ console.error(theme.error(t('diff.not_repo')));
2010
+ continue;
2011
+ }
2012
+ const out = await runGit(diffGitArgs(staged), currentWorkdir, 20_000);
2013
+ console.log(theme.system(formatDiff(out, { maxLines: 400 })));
2014
+ continue;
2015
+ }
2016
+ if (lower === '/cost' || lower === '/usage') {
2017
+ let stats = summarizeTranscript([]);
2018
+ if (transcript.file) {
2019
+ try {
2020
+ const body = await fs.readFile(transcript.file, 'utf-8');
2021
+ stats = summarizeTranscript(parseTranscript(body));
2022
+ }
2023
+ catch {
2024
+ // best-effort
2025
+ }
2026
+ }
2027
+ console.log(theme.system(renderCost(stats, transcript.file)));
2028
+ continue;
2029
+ }
2030
+ if (lower === '/export' || lower.startsWith('/export ')) {
2031
+ const arg = trimmed.slice('/export'.length).trim();
2032
+ const target = arg
2033
+ ? path.resolve(currentWorkdir, arg)
2034
+ : defaultExportPath(currentWorkdir);
2035
+ const rel = path.relative(sandboxRoot, target);
2036
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
2037
+ console.error(theme.error(t('export.outside')));
2038
+ continue;
2039
+ }
2040
+ let entries = [];
2041
+ if (transcript.file) {
2042
+ try {
2043
+ entries = parseTranscript(await fs.readFile(transcript.file, 'utf-8'));
2044
+ }
2045
+ catch {
2046
+ entries = [];
2047
+ }
2048
+ }
2049
+ const md = formatExport(entries, {
2050
+ chatId: currentChatId,
2051
+ workdir: currentWorkdir,
2052
+ });
2053
+ await fs.writeFile(target, md, 'utf-8');
2054
+ console.log(theme.assistant(t('export.done', { v: target })));
2055
+ continue;
2056
+ }
2057
+ if (lower === '/doctor') {
2058
+ const { runGit } = await import('./gitTools.js');
2059
+ let gitOk = false;
2060
+ let gitBranch = null;
2061
+ try {
2062
+ const probe = await runGit('git rev-parse --is-inside-work-tree', currentWorkdir, 5000);
2063
+ gitOk = probe.trim() === 'true';
2064
+ if (gitOk) {
2065
+ gitBranch = (await runGit('git branch --show-current', currentWorkdir, 5000)).trim();
2066
+ }
2067
+ }
2068
+ catch {
2069
+ gitOk = false;
2070
+ }
2071
+ let clipboardTool = null;
2072
+ try {
2073
+ clipboardTool = hasClipboardTool() ? 'available' : null;
2074
+ }
2075
+ catch {
2076
+ clipboardTool = null;
2077
+ }
2078
+ const mcpStatus = mcpPool
2079
+ ? mcpPool.status()
2080
+ : { servers: [], toolCount: 0 };
2081
+ console.log(theme.system(renderDoctor({
2082
+ nodeVersion: process.version,
2083
+ platform: process.platform,
2084
+ workdir: currentWorkdir,
2085
+ gitOk,
2086
+ gitBranch,
2087
+ configOk: true,
2088
+ browserChannel: config.browserChannel,
2089
+ clipboardTool,
2090
+ mcpServers: mcpStatus.servers.length,
2091
+ mcpTools: mcpStatus.toolCount,
2092
+ transcriptOk: !!transcript.file,
2093
+ })));
2094
+ continue;
2095
+ }
2096
+ if (lower === '/permissions' || lower === '/allowed-tools') {
2097
+ console.log(theme.system(renderPermissions({
2098
+ write: config.confirmation.write,
2099
+ edit: config.confirmation.edit,
2100
+ bash: config.confirmation.bash,
2101
+ alwaysConfirm: config.confirmation.alwaysConfirm,
2102
+ })));
2103
+ continue;
2104
+ }
2105
+ if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
2106
+ const arg = trimmed.slice('/add-dir'.length).trim();
2107
+ const res = resolveExtraDir(arg, currentWorkdir);
2108
+ if ('error' in res) {
2109
+ console.error(theme.warn(res.error));
2110
+ continue;
2111
+ }
2112
+ const stat = await fs.stat(res.path).catch(() => null);
2113
+ if (!stat || !stat.isDirectory()) {
2114
+ console.error(theme.error(t('adddir.not_dir', { v: res.path })));
2115
+ continue;
2116
+ }
2117
+ console.log(theme.system(t('adddir.note', { v: res.path })));
2118
+ continue;
2119
+ }
2120
+ if (lower === '/review' || lower.startsWith('/review ')) {
2121
+ const rest = trimmed.slice('/review'.length).trim();
2122
+ const staged = rest.indexOf("--staged") !== -1;
2123
+ const focus = rest.replace(/--staged/g, '').trim();
2124
+ const reviewTask = buildReviewPrompt(focus, staged);
2125
+ const reviewTools = mod.createTools(currentWorkdir, { undo });
2126
+ if (editor)
2127
+ editor.busy = true;
2128
+ try {
2129
+ await runTask(browser, reviewTools, reviewTask, currentWorkdir, {
2130
+ transcript,
2131
+ freshChat: false,
2132
+ sendSystemPrompt: false,
2133
+ ui: editor || null,
2134
+ onChatReady: (chatId) => {
2135
+ if (chatId) {
2136
+ currentChatId = chatId;
2137
+ saveLastChat(chatId, currentWorkdir);
2138
+ }
2139
+ },
2140
+ });
2141
+ }
2142
+ finally {
2143
+ if (editor)
2144
+ editor.busy = false;
2145
+ }
2146
+ continue;
2147
+ }
1988
2148
  if (lower === '/undo') {
1989
2149
  const result = await undo.undoLast();
1990
2150
  if (result.ok && result.record) {