zames_pro 2.53.1 → 2.55.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/README.md CHANGED
@@ -90,6 +90,9 @@ The prompt is a small line editor with persistent history:
90
90
  delete the word before the cursor, `Ctrl+←`/`Ctrl+→` — move by words.
91
91
  - `\` + `Enter`, `Ctrl+J`, `Ctrl+Enter` or `Shift+Enter` — insert a newline.
92
92
  - `/` + `Tab` — slash-command hints and completion.
93
+ - `!command` — run a shell command directly, bypassing the model (like
94
+ Claude Code's bash mode). The same sandbox guard as the `Bash` tool applies,
95
+ so a direct command cannot leave the project either.
93
96
 
94
97
  Set `NO_COLOR=1` to disable colors (a calm default palette is used otherwise).
95
98
 
@@ -160,8 +163,6 @@ Codex CLI:
160
163
  - /export [file] — write the session transcript to a Markdown file
161
164
  (zames-export-<stamp>.md by default).
162
165
  - /doctor — diagnose node, git, config, browser, clipboard and MCP.
163
- - /permissions — show the confirmation settings (Write/Edit/Bash + the
164
- alwaysConfirm regex list).
165
166
  - /add-dir <path> — validate an extra directory (the sandbox is fixed at
166
167
  startup; relaunch with --dir to write there).
167
168
  - /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
@@ -207,7 +208,8 @@ workflows from your repository and from `~/.zames`.
207
208
  will explore the project and write an AGENTS.md based on the real build/test
208
209
  commands and conventions (use `/init --force` to overwrite an existing file).
209
210
  - **MEMORY.md** — durable notes that persist between sessions. The agent
210
- appends useful facts here; you can edit it by hand.
211
+ appends useful facts here; you can edit it by hand, or add one from the
212
+ prompt with `/remember <text>`.
211
213
  - **Skills** — a folder with a `SKILL.md` file (YAML frontmatter: `name`,
212
214
  `description`, optional `allowed-tools`, `user-invokable`) plus any helper
213
215
  files. Discovered under `.zames/skills/`, `.claude/skills/`,
@@ -223,6 +225,7 @@ Skills and custom commands show up in the «/» completion list and in `/help`.
223
225
  ```
224
226
  /skills list discovered skills
225
227
  /memory show AGENTS.md / MEMORY.md in effect
228
+ /remember <text> append a durable note to MEMORY.md
226
229
  /init [--force] analyze the project and create AGENTS.md
227
230
  ```
228
231
 
@@ -286,7 +289,6 @@ Examples:
286
289
 
287
290
  ```
288
291
  /config set maxIterations 20
289
- /config set confirmation.bash false
290
292
  /config set browser.deepThinking true # DeepSeek Deep thinking (slow; reasoning is hidden)
291
293
  /config set browser.webSearch false # DeepSeek Smart web search
292
294
  /config lang en
package/dist/commands.js CHANGED
@@ -2,7 +2,7 @@ import path from 'path';
2
2
  import { isImageName } from './attachments.js';
3
3
  import { translate } from './i18n.js';
4
4
  // Helpers for the extra slash commands (/diff, /cost, /export, /doctor,
5
- // /permissions, /review, /add-dir). Pure functions, unit-tested without a
5
+ // /review, /add-dir). Pure functions, unit-tested without a
6
6
  // live agent/browser. index.ts only renders their output.
7
7
  const NL = String.fromCharCode(10);
8
8
  const CR = String.fromCharCode(13);
@@ -234,22 +234,6 @@ export function renderDoctor(d, t = translate('en')) {
234
234
  }
235
235
  return t('doctor.title') + NL + rows.join(NL);
236
236
  }
237
- export function renderPermissions(p, t = translate('en')) {
238
- const onoff = (b) => (b ? t('perm.ask') : t('perm.allow'));
239
- const lines = [];
240
- lines.push(t('perm.title'));
241
- lines.push(t('perm.write', { v: onoff(p.write) }));
242
- lines.push(t('perm.edit', { v: onoff(p.edit) }));
243
- lines.push(t('perm.bash', { v: onoff(p.bash) }));
244
- if (p.alwaysConfirm.length) {
245
- lines.push(t('perm.always'));
246
- for (const re of p.alwaysConfirm)
247
- lines.push(' ' + re);
248
- }
249
- lines.push('');
250
- lines.push(t('perm.change_hint'));
251
- return lines.join(NL);
252
- }
253
237
  // ---------- /add-dir ----------
254
238
  export function resolveExtraDir(input, workdir, t = translate('en')) {
255
239
  const raw = String(input ?? '').trim();
@@ -488,9 +472,10 @@ export function hasQueuedJob(queue, jobId) {
488
472
  // True when the queued message is a slash-command. It must NOT be merged into
489
473
  // a task: the main loop has to execute it (e.g. /compact, /new).
490
474
  export function isSlashCommand(text) {
491
- return String(text ?? '')
492
- .trim()
493
- .startsWith('/');
475
+ const s = String(text ?? '').trim();
476
+ // `!command` is also an operator command (run the shell directly) — it
477
+ // must NOT be batched into a task sent to the model.
478
+ return s.startsWith('/') || s.startsWith('!');
494
479
  }
495
480
  export function parseLiveToggle(text) {
496
481
  const m = /^\/(thinking|web|websearch|search)(?:\s+(\S+))?\s*$/i.exec(String(text ?? '').trim());
package/dist/config.js CHANGED
@@ -24,21 +24,6 @@ export const DEFAULTS = {
24
24
  headless: true,
25
25
  debug: false,
26
26
  browserChannel: null,
27
- confirmation: {
28
- write: true,
29
- edit: true,
30
- bash: true,
31
- alwaysConfirm: [
32
- 'rm\s+-rf',
33
- 'rmdir\s+/s',
34
- 'del\s+/[sqf]',
35
- 'format\s+[a-z]:',
36
- 'shutdown',
37
- 'reg\s+delete',
38
- 'remove-item.*-recurse',
39
- 'git\s+push\s+--force',
40
- ],
41
- },
42
27
  undo: {
43
28
  enabled: true,
44
29
  maxBackups: 200,
@@ -169,24 +154,6 @@ export const CONFIG_SCHEMA = [
169
154
  labelKey: 'cfg.f.hotReload',
170
155
  groupKey: 'cfg.group.agent',
171
156
  },
172
- {
173
- path: 'confirmation.write',
174
- type: 'boolean',
175
- labelKey: 'cfg.f.confirmation_write',
176
- groupKey: 'cfg.group.confirmation',
177
- },
178
- {
179
- path: 'confirmation.edit',
180
- type: 'boolean',
181
- labelKey: 'cfg.f.confirmation_edit',
182
- groupKey: 'cfg.group.confirmation',
183
- },
184
- {
185
- path: 'confirmation.bash',
186
- type: 'boolean',
187
- labelKey: 'cfg.f.confirmation_bash',
188
- groupKey: 'cfg.group.confirmation',
189
- },
190
157
  {
191
158
  path: 'undo.enabled',
192
159
  type: 'boolean',
package/dist/i18n.js CHANGED
@@ -135,6 +135,10 @@ export const CATALOG = {
135
135
  ru: 'Esc, Ctrl+C прервать текущую генерацию',
136
136
  en: 'Esc, Ctrl+C abort current generation',
137
137
  },
138
+ 'help.key.bang': {
139
+ ru: '!команда выполнить shell-команду напрямую (мимо модели)',
140
+ en: '!command run a shell command directly (bypass the model)',
141
+ },
138
142
  'help.commands': { ru: 'Обычные команды:', en: 'Commands:' },
139
143
  'help.cmd.new': {
140
144
  ru: '/new, /clear новый чат (сброс контекста)',
@@ -205,10 +209,6 @@ export const CATALOG = {
205
209
  ru: '/doctor диагностика установки и конфига',
206
210
  en: '/doctor diagnose install and config',
207
211
  },
208
- 'help.cmd.permissions': {
209
- ru: '/permissions настройки подтверждений',
210
- en: '/permissions confirmation settings',
211
- },
212
212
  'help.cmd.add_dir': {
213
213
  ru: '/add-dir <path> проверить директорию',
214
214
  en: '/add-dir <path> validate a directory',
@@ -393,6 +393,14 @@ export const CATALOG = {
393
393
  en: 'Instructions (AGENTS.md):',
394
394
  },
395
395
  'memory.memory': { ru: 'Память (MEMORY.md):', en: 'Memory (MEMORY.md):' },
396
+ 'remember.usage': {
397
+ ru: 'Использование: /remember <текст заметки>',
398
+ en: 'Usage: /remember <note text>',
399
+ },
400
+ 'remember.saved': {
401
+ ru: '🧠 Заметка добавлена в {v}',
402
+ en: '🧠 Note appended to {v}',
403
+ },
396
404
  'init.exists': {
397
405
  ru: 'AGENTS.md уже существует: {v}',
398
406
  en: 'AGENTS.md already exists: {v}',
@@ -424,6 +432,10 @@ export const CATALOG = {
424
432
  ru: '/memory показать MEMORY.md и AGENTS.md',
425
433
  en: '/memory show MEMORY.md and AGENTS.md',
426
434
  },
435
+ 'help.cmd.remember': {
436
+ ru: '/remember <текст> дописать заметку в MEMORY.md',
437
+ en: '/remember <text> append a note to MEMORY.md',
438
+ },
427
439
  'help.cmd.init': {
428
440
  ru: '/init [--force] изучить проект и создать AGENTS.md',
429
441
  en: '/init [--force] analyze the project and create AGENTS.md',
@@ -607,6 +619,14 @@ export const CATALOG = {
607
619
  en: 'Unknown command: {v}. Type /help.',
608
620
  },
609
621
  'msg.bye': { ru: 'Выход.', en: 'Bye.' },
622
+ 'shell.empty': {
623
+ ru: 'После ! нужна команда, например !git status.',
624
+ en: 'A command is required after !, e.g. !git status.',
625
+ },
626
+ 'shell.running': {
627
+ ru: 'выполняю shell-команду...',
628
+ en: 'running shell command...',
629
+ },
610
630
  'msg.exit_summary': {
611
631
  ru: 'Транскрипт: {transcript} · чат: {chat}',
612
632
  en: 'Transcript: {transcript} · chat: {chat}',
@@ -722,24 +742,6 @@ export const CATALOG = {
722
742
  ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
723
743
  en: 'no cached UA yet (first start relaunches once)',
724
744
  },
725
- // ---------- /permissions ----------
726
- 'perm.title': {
727
- ru: 'Права инструментов (настройки подтверждений):',
728
- en: 'Tool permissions (confirmation settings):',
729
- },
730
- 'perm.write': { ru: ' Write: {v}', en: ' Write: {v}' },
731
- 'perm.edit': { ru: ' Edit: {v}', en: ' Edit: {v}' },
732
- 'perm.bash': { ru: ' Bash: {v}', en: ' Bash: {v}' },
733
- 'perm.ask': { ru: 'спрашивать', en: 'ask' },
734
- 'perm.allow': { ru: 'разрешить', en: 'allow' },
735
- 'perm.always': {
736
- ru: ' Всегда подтверждать (regex):',
737
- en: ' Always confirm (regex):',
738
- },
739
- 'perm.change_hint': {
740
- ru: 'Изменить: /config set confirmation.write false (и .edit / .bash)',
741
- en: 'Change with: /config set confirmation.write false (and .edit / .bash)',
742
- },
743
745
  // ---------- /add-dir ----------
744
746
  'adddir.usage': {
745
747
  ru: 'Использование: /add-dir <path>',
@@ -1213,7 +1215,6 @@ export const CATALOG = {
1213
1215
  // ---------- config menu ----------
1214
1216
  'cfg.group.ui': { ru: 'Интерфейс', en: 'Interface' },
1215
1217
  'cfg.group.agent': { ru: 'Агент', en: 'Agent' },
1216
- 'cfg.group.confirmation': { ru: 'Подтверждения', en: 'Confirmations' },
1217
1218
  'cfg.group.undo': { ru: 'Откат (undo)', en: 'Undo' },
1218
1219
  'cfg.group.transcript': { ru: 'Транскрипт', en: 'Transcript' },
1219
1220
  'cfg.group.browser': { ru: 'Браузер / DeepSeek', en: 'Browser / DeepSeek' },
@@ -1242,18 +1243,6 @@ export const CATALOG = {
1242
1243
  ru: 'Авто-перечитывание модулей перед задачей',
1243
1244
  en: 'Auto-reload logic modules before each task',
1244
1245
  },
1245
- 'cfg.f.confirmation_write': {
1246
- ru: 'Спрашивать перед Write',
1247
- en: 'Ask before Write',
1248
- },
1249
- 'cfg.f.confirmation_edit': {
1250
- ru: 'Спрашивать перед Edit',
1251
- en: 'Ask before Edit',
1252
- },
1253
- 'cfg.f.confirmation_bash': {
1254
- ru: 'Спрашивать перед Bash',
1255
- en: 'Ask before Bash',
1256
- },
1257
1246
  'cfg.f.undo_enabled': {
1258
1247
  ru: 'Включить бэкапы для отката',
1259
1248
  en: 'Enable undo backups',
@@ -1493,8 +1482,8 @@ export const CATALOG = {
1493
1482
  en: 'Config write scope: {v} ({file})',
1494
1483
  },
1495
1484
  'cfg.scope_usage': {
1496
- ru: 'Использование: /config scope <project|home>. project = .zamesrc.json (в git), home = ~/.zames/. Для личных настроек (confirmations) выбери home, чтобы не коммитить их.',
1497
- en: 'Usage: /config scope <project|home>. project = .zamesrc.json (committed), home = ~/.zames/. Use home for personal settings (confirmations) so they are not committed.',
1485
+ ru: 'Использование: /config scope <project|home>. project = .zamesrc.json (в git), home = ~/.zames/. Для личных настроек выбери home, чтобы не коммитить их.',
1486
+ en: 'Usage: /config scope <project|home>. project = .zamesrc.json (committed), home = ~/.zames/. Use home for personal settings so they are not committed.',
1498
1487
  },
1499
1488
  'cfg.scope_set': {
1500
1489
  ru: 'Запись конфига: {v} ({file})',
package/dist/index.js CHANGED
@@ -18,11 +18,12 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
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, 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);
@@ -167,7 +168,6 @@ const RELOADABLE = [
167
168
  'self-review',
168
169
  'diff',
169
170
  'undo',
170
- 'confirm',
171
171
  'transcript',
172
172
  'spinner',
173
173
  'config',
@@ -309,6 +309,7 @@ ${theme.bold(t('help.while_working'))}
309
309
  ${t('help.key.backslash')}
310
310
  ${t('help.key.attach')}
311
311
  ${t('help.key.esc')}
312
+ ${t('help.key.bang')}
312
313
 
313
314
  ${theme.bold(t('help.commands'))}
314
315
  ${t('help.cmd.new')}
@@ -329,7 +330,6 @@ ${theme.bold(t('help.commands'))}
329
330
  ${t('help.cmd.cost')}
330
331
  ${t('help.cmd.export')}
331
332
  ${t('help.cmd.doctor')}
332
- ${t('help.cmd.permissions')}
333
333
  ${t('help.cmd.add_dir')}
334
334
  ${t('help.cmd.review')}
335
335
  ${t('help.cmd.goal')}
@@ -343,6 +343,11 @@ ${theme.bold(t('help.commands'))}
343
343
  ${t('help.cmd.tasks')}
344
344
  ${t('help.cmd.config')}
345
345
  ${t('help.cmd.lang')}
346
+ ${t('help.cmd.skills')}
347
+ ${t('help.cmd.memory')}
348
+ ${t('help.cmd.remember')}
349
+ ${t('help.cmd.init')}
350
+ ${t('help.cmd.mcp')}
346
351
  ${t('help.cmd.debug_dom')}
347
352
  ${t('help.cmd.help')}
348
353
  ${t('help.cmd.exit')}
@@ -396,7 +401,6 @@ const SLASH_COMMANDS = [
396
401
  { name: '/cost', key: 'help.cmd.cost' },
397
402
  { name: '/export', key: 'help.cmd.export' },
398
403
  { name: '/doctor', key: 'help.cmd.doctor' },
399
- { name: '/permissions', key: 'help.cmd.permissions' },
400
404
  { name: '/add-dir', key: 'help.cmd.add_dir' },
401
405
  { name: '/review', key: 'help.cmd.review' },
402
406
  { name: '/goal', key: 'help.cmd.goal' },
@@ -411,6 +415,7 @@ const SLASH_COMMANDS = [
411
415
  { name: '/config', key: 'help.cmd.config' },
412
416
  { name: '/skills', key: 'help.cmd.skills' },
413
417
  { name: '/memory', key: 'help.cmd.memory' },
418
+ { name: '/remember', key: 'help.cmd.remember' },
414
419
  { name: '/init', key: 'help.cmd.init' },
415
420
  { name: '/mcp', key: 'help.cmd.mcp' },
416
421
  { name: '/debug-dom', key: 'help.cmd.debug_dom' },
@@ -2153,6 +2158,36 @@ async function main() {
2153
2158
  const lower = trimmed.toLowerCase();
2154
2159
  if (['/exit', '/quit', 'exit', 'quit'].includes(lower))
2155
2160
  break;
2161
+ // `!command` — run a shell command directly, without asking the model
2162
+ // (like Claude Code's bash mode). The same sandbox guard as the Bash tool
2163
+ // applies, so a direct command cannot leave the project either.
2164
+ if (trimmed.startsWith('!')) {
2165
+ const cmd = trimmed.slice(1).trim();
2166
+ if (!cmd) {
2167
+ console.error(theme.warn(t('shell.empty')));
2168
+ continue;
2169
+ }
2170
+ try {
2171
+ assertCommandInsideRoot(currentWorkdir, cmd);
2172
+ }
2173
+ catch (e) {
2174
+ console.error(theme.error(e.message));
2175
+ continue;
2176
+ }
2177
+ transcript.log('shell_command', { command: cmd });
2178
+ if (editor)
2179
+ editor.lock(t('shell.running'));
2180
+ try {
2181
+ const out = await runShell(currentWorkdir, cmd);
2182
+ console.log(theme.system(out));
2183
+ transcript.log('shell_result', { command: cmd, result: out });
2184
+ }
2185
+ finally {
2186
+ if (editor)
2187
+ editor.unlock();
2188
+ }
2189
+ continue;
2190
+ }
2156
2191
  if (lower === '/help' ||
2157
2192
  lower === 'help' ||
2158
2193
  lower.startsWith('/help ') ||
@@ -2771,6 +2806,34 @@ async function main() {
2771
2806
  show(t('memory.memory'), ctx.memory);
2772
2807
  continue;
2773
2808
  }
2809
+ // `/remember <text>` — append a durable note to the project MEMORY.md.
2810
+ // Claude Code has the same idea (# shortcut): a fact worth keeping across
2811
+ // sessions is written to disk instead of living only in this chat. We
2812
+ // write to the workdir's own MEMORY.md (the deepest file the loader reads)
2813
+ // and create it when missing.
2814
+ if (lower === '/remember' || lower.startsWith('/remember ')) {
2815
+ const note = trimmed.slice('/remember'.length).trim();
2816
+ if (!note) {
2817
+ console.error(theme.warn(t('remember.usage')));
2818
+ continue;
2819
+ }
2820
+ const memFile = path.join(currentWorkdir, 'MEMORY.md');
2821
+ const stamp = new Date().toISOString().slice(0, 10);
2822
+ let body;
2823
+ try {
2824
+ body = await fs.readFile(memFile, 'utf-8');
2825
+ }
2826
+ catch {
2827
+ body = '# MEMORY\n\nDurable notes accumulated across sessions.\n';
2828
+ }
2829
+ if (body.length && !body.endsWith(NL))
2830
+ body += NL;
2831
+ body += NL + '- (' + stamp + ') ' + note + NL;
2832
+ await fs.writeFile(memFile, body, 'utf-8');
2833
+ transcript.log('remember', { note, file: memFile });
2834
+ console.log(theme.assistant(t('remember.saved', { v: memFile })));
2835
+ continue;
2836
+ }
2774
2837
  if (lower === '/init' || lower.startsWith('/init ')) {
2775
2838
  const force = lower.includes('--force');
2776
2839
  const target = path.join(currentWorkdir, 'AGENTS.md');
@@ -3037,15 +3100,6 @@ async function main() {
3037
3100
  }, t)));
3038
3101
  continue;
3039
3102
  }
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
3103
  if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
3050
3104
  const arg = trimmed.slice('/add-dir'.length).trim();
3051
3105
  const res = resolveExtraDir(arg, currentWorkdir, t);
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) {
@@ -61,6 +61,45 @@ async function copyDirJsFiles(from, to) {
61
61
  }
62
62
  return copied;
63
63
  }
64
+ // Root files and folders a review often needs but that are NOT part of the
65
+ // editable src/ set (scripts, lint/ts configs). They are copied into a
66
+ // read-only `_context/` subfolder so the reviewer can READ them while the
67
+ // .ts-only diff/apply logic stays untouched (a stray edit here would never be
68
+ // applied back and would only confuse the report).
69
+ const CONTEXT_FILES = [
70
+ 'package.json',
71
+ 'eslint.config.js',
72
+ '.prettierrc',
73
+ 'tsconfig.json',
74
+ 'tsconfig.build.json',
75
+ ];
76
+ async function copyContextFiles(srcDir, snapDir) {
77
+ const repoRoot = path.resolve(srcDir, '..');
78
+ const ctxDir = path.join(snapDir, '_context');
79
+ for (const rel of CONTEXT_FILES) {
80
+ const data = await fs
81
+ .readFile(path.join(repoRoot, rel), 'utf-8')
82
+ .catch(() => null);
83
+ if (data === null)
84
+ continue;
85
+ await ensureDir(ctxDir);
86
+ await fs.writeFile(path.join(ctxDir, rel), data, 'utf-8');
87
+ }
88
+ const scripts = await fs
89
+ .readdir(path.join(repoRoot, 'scripts'))
90
+ .catch(() => []);
91
+ if (scripts.length) {
92
+ await ensureDir(path.join(ctxDir, 'scripts'));
93
+ for (const f of scripts) {
94
+ const data = await fs
95
+ .readFile(path.join(repoRoot, 'scripts', f), 'utf-8')
96
+ .catch(() => null);
97
+ if (data === null)
98
+ continue;
99
+ await fs.writeFile(path.join(ctxDir, 'scripts', f), data, 'utf-8');
100
+ }
101
+ }
102
+ }
64
103
  // ---------- /self-review ----------
65
104
  export async function selfReview({ browser, focus, transcript, locale, }) {
66
105
  const t = translator(locale);
@@ -74,12 +113,9 @@ export async function selfReview({ browser, focus, transcript, locale, }) {
74
113
  console.error(theme.error('\n✖ ' + t('self.no_src', { dir: SRC_DIR })));
75
114
  throw new Error(t('self.no_src_err'));
76
115
  }
77
- // Copy package.json for context — so the agent sees the dependencies
78
- try {
79
- const pkg = await fs.readFile(path.resolve(SRC_DIR, '..', 'package.json'), 'utf-8');
80
- await fs.writeFile(path.join(snapDir, 'package.json'), pkg, 'utf-8');
81
- }
82
- catch { }
116
+ // Copy root configs/scripts into a read-only _context/ subfolder so the
117
+ // reviewer can read them (the editable set stays .ts-only).
118
+ await copyContextFiles(SRC_DIR, snapDir);
83
119
  console.log(theme.system('\n' + t('self.snapshot', { dir: snapDir })));
84
120
  console.log(theme.system(t('self.files', { n: copied.length, list: copied.join(', ') })));
85
121
  console.log(theme.system(t('self.starting') + '\n'));
@@ -248,6 +284,8 @@ function buildReviewPrompt({ focus, snapDir, }) {
248
284
  return `You are a senior JavaScript/Node.js engineer. You are reviewing your own source code — a Playwright-based coding agent that talks to the DeepSeek web chat as an LLM backend.
249
285
 
250
286
  All source files are in your working directory: ${snapDir}
287
+ Root configs and scripts (package.json, tsconfig, eslint, scripts/) are copied
288
+ into the read-only _context/ subfolder there — read them when relevant.
251
289
  ${focusLine}
252
290
  ## Your job
253
291
 
package/dist/shell.js ADDED
@@ -0,0 +1,72 @@
1
+ import { exec } from 'child_process';
2
+ import path from 'path';
3
+ // Shared shell runner for the Bash tool and the operator's `!command` escape.
4
+ // Kept in its own module so both call sites use the SAME sandbox guard and the
5
+ // SAME abort wiring; a second, ad-hoc runner would drift from the real one.
6
+ // Sandbox (option A): don't let a shell command go above the project root.
7
+ // This is a protective barrier, not full OS isolation.
8
+ export function assertCommandInsideRoot(root, command) {
9
+ const cmd = String(command || '');
10
+ const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi;
11
+ let m;
12
+ while ((m = cdRe.exec(cmd))) {
13
+ const raw = m[1].trim();
14
+ if (!raw || raw === '-')
15
+ continue;
16
+ const target = path.resolve(root, raw);
17
+ const rel = path.relative(root, target);
18
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
19
+ throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
20
+ }
21
+ }
22
+ }
23
+ export function runShell(workdir, command, timeout = 30_000, signal) {
24
+ return new Promise((resolve) => {
25
+ const options = {
26
+ cwd: workdir,
27
+ timeout,
28
+ maxBuffer: 1024 * 1024 * 8,
29
+ windowsHide: true,
30
+ env: { ...process.env },
31
+ // Abort the child process when the operator pressed Esc/Ctrl+C.
32
+ // Without it a long `npm test` kept running even after the stop.
33
+ signal,
34
+ };
35
+ if (process.platform === 'win32') {
36
+ options.shell = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
37
+ }
38
+ else {
39
+ options.shell = '/bin/sh';
40
+ }
41
+ exec(command, options, (err, stdout, stderr) => {
42
+ const out = (stdout || '').toString();
43
+ const errStr = (stderr || '').toString();
44
+ if (!err) {
45
+ const combined = (out + errStr).trim();
46
+ resolve(combined || '(command produced no output)');
47
+ return;
48
+ }
49
+ const parts = [];
50
+ if (err.killed) {
51
+ parts.push('\u23f1 Timeout after ' + timeout + 'ms \u2014 process killed.');
52
+ }
53
+ else if (err.code === 'ABORT_ERR') {
54
+ parts.push('(aborted by the operator)');
55
+ }
56
+ else if (err.code !== undefined && err.code !== null) {
57
+ parts.push('Exit code: ' + err.code);
58
+ }
59
+ else if (err.signal) {
60
+ parts.push('Killed by signal: ' + err.signal);
61
+ }
62
+ else {
63
+ parts.push('Error: ' + err.message);
64
+ }
65
+ if (out.trim())
66
+ parts.push('stdout:\n' + out.trim());
67
+ if (errStr.trim())
68
+ parts.push('stderr:\n' + errStr.trim());
69
+ resolve(parts.join('\n'));
70
+ });
71
+ });
72
+ }
package/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { exec } from 'child_process';
3
+ import { assertCommandInsideRoot, runShell } from './shell.js';
4
4
  import { createGitTools } from './gitTools.js';
5
5
  import { createWebTools } from './web.js';
6
6
  import { createExtraTools } from './extraTools.js';
@@ -20,71 +20,6 @@ export function createTools(workdir, { undo, todos } = {}) {
20
20
  }
21
21
  return resolved;
22
22
  };
23
- // Sandbox (option A): don't let the command go above root.
24
- // This is a protective barrier, not full OS isolation.
25
- const assertCommandInsideRoot = (command) => {
26
- const cmd = String(command || '');
27
- const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi;
28
- let m;
29
- while ((m = cdRe.exec(cmd))) {
30
- const raw = m[1].trim();
31
- if (!raw || raw === '-')
32
- continue;
33
- const target = path.resolve(root, raw);
34
- const rel = path.relative(root, target);
35
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
36
- throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
37
- }
38
- }
39
- };
40
- const runShell = (command, timeout = 30_000, signal) => new Promise((resolve) => {
41
- const options = {
42
- cwd: workdir,
43
- timeout,
44
- maxBuffer: 1024 * 1024 * 8,
45
- windowsHide: true,
46
- env: { ...process.env },
47
- // Abort the child process when the operator pressed Esc/Ctrl+C.
48
- // Without it a long `npm test` kept running even after the stop.
49
- signal,
50
- };
51
- if (process.platform === 'win32') {
52
- options.shell = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
53
- }
54
- else {
55
- options.shell = '/bin/sh';
56
- }
57
- exec(command, options, (err, stdout, stderr) => {
58
- const out = (stdout || '').toString();
59
- const errStr = (stderr || '').toString();
60
- if (!err) {
61
- const combined = (out + errStr).trim();
62
- resolve(combined || '(command produced no output)');
63
- return;
64
- }
65
- const parts = [];
66
- if (err.killed) {
67
- parts.push(`⏱ Timeout after ${timeout}ms — process killed.`);
68
- }
69
- else if (err.code === 'ABORT_ERR') {
70
- parts.push('(aborted by the operator)');
71
- }
72
- else if (err.code !== undefined && err.code !== null) {
73
- parts.push(`Exit code: ${err.code}`);
74
- }
75
- else if (err.signal) {
76
- parts.push(`Killed by signal: ${err.signal}`);
77
- }
78
- else {
79
- parts.push(`Error: ${err.message}`);
80
- }
81
- if (out.trim())
82
- parts.push(`stdout:\n${out.trim()}`);
83
- if (errStr.trim())
84
- parts.push(`stderr:\n${errStr.trim()}`);
85
- resolve(parts.join('\n'));
86
- });
87
- });
88
23
  // Extracts text from content/content_base64. base64 is needed because the
89
24
  // channel that delivers the model's answer may corrupt characters ($,
90
25
  // backslashes, newlines). base64 consists only of [A-Za-z0-9+/=] and is not
@@ -202,8 +137,8 @@ export function createTools(workdir, { undo, todos } = {}) {
202
137
  'For git use the Git* tools. For the web use WebFetch / WebSearch.',
203
138
  parameters: { command: 'string', timeout: 'number?' },
204
139
  fn: async ({ command, timeout }, ctx) => {
205
- assertCommandInsideRoot(req(command, 'command'));
206
- return runShell(req(command, 'command'), timeout, ctx?.signal);
140
+ assertCommandInsideRoot(root, req(command, 'command'));
141
+ return runShell(workdir, req(command, 'command'), timeout, ctx?.signal);
207
142
  },
208
143
  },
209
144
  {
@@ -245,11 +180,11 @@ export function createTools(workdir, { undo, todos } = {}) {
245
180
  if (process.platform === 'win32') {
246
181
  const escaped = pat.replace(/"/g, '\\"');
247
182
  const scope = searchPath ? '"' + target + '\\*' : '*';
248
- return runShell(`findstr /s /n /r /c:"${escaped}" ` + scope);
183
+ return runShell(workdir, `findstr /s /n /r /c:"${escaped}" ` + scope);
249
184
  }
250
185
  const flags = mode === 'files_only' ? '-rlE' : mode === 'count' ? '-rcE' : '-rnE';
251
186
  const includeArg = inc ? ' --include=' + JSON.stringify(inc) : '';
252
- return runShell(`grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
187
+ return runShell(workdir, `grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
253
188
  `${JSON.stringify(target)} || true`);
254
189
  },
255
190
  },
package/dist/web.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { chromium } from 'playwright';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { isIP } from 'node:net';
2
4
  const DEFAULT_TIMEOUT = 20_000;
3
5
  const MAX_TEXT = 12_000;
4
6
  // Removes scripts, styles, and turns HTML into readable text.
@@ -42,6 +44,102 @@ function htmlToText(html) {
42
44
  .join('\n');
43
45
  return s;
44
46
  }
47
+ // ---------- SSRF guard ----------
48
+ //
49
+ // WebFetch takes an arbitrary URL from the model. Without a guard it could be
50
+ // talked into reading cloud metadata (169.254.169.254), internal admin panels
51
+ // or localhost services. We resolve the hostname and refuse private / loopback
52
+ // / link-local addresses. Best-effort: a DNS rebind between the check and the
53
+ // fetch is out of scope for a CLI tool, but the common cases are covered.
54
+ export function isPrivateIp(ip) {
55
+ const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
56
+ if (v4) {
57
+ const [a, b] = v4.slice(1).map(Number);
58
+ if (a === 10 || a === 127 || a === 0)
59
+ return true;
60
+ if (a === 169 && b === 254)
61
+ return true; // link-local + cloud metadata
62
+ if (a === 172 && b >= 16 && b <= 31)
63
+ return true;
64
+ if (a === 192 && b === 168)
65
+ return true;
66
+ if (a === 100 && b >= 64 && b <= 127)
67
+ return true; // CGNAT
68
+ return false;
69
+ }
70
+ const low = ip.toLowerCase();
71
+ if (low === '::1' || low === '::')
72
+ return true;
73
+ if (low.startsWith('fe80') || low.startsWith('fc') || low.startsWith('fd')) {
74
+ return true;
75
+ }
76
+ const mapped = low.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
77
+ if (mapped)
78
+ return isPrivateIp(mapped[1]);
79
+ return false;
80
+ }
81
+ export function isPrivateHostname(host) {
82
+ const h = host.toLowerCase().replace(/\.$/, '');
83
+ if (!h)
84
+ return true;
85
+ if (h === 'localhost' || h.endsWith('.localhost'))
86
+ return true;
87
+ if (h === 'metadata.google.internal')
88
+ return true;
89
+ return false;
90
+ }
91
+ async function assertPublicUrl(rawUrl) {
92
+ const u = new URL(rawUrl);
93
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
94
+ throw new Error(`unsupported protocol: ${u.protocol}`);
95
+ }
96
+ const host = u.hostname;
97
+ if (isPrivateHostname(host)) {
98
+ throw new Error(`blocked private host: ${host}`);
99
+ }
100
+ if (isIP(host)) {
101
+ if (isPrivateIp(host))
102
+ throw new Error(`blocked private address: ${host}`);
103
+ return;
104
+ }
105
+ try {
106
+ const addrs = await lookup(host, { all: true });
107
+ for (const a of addrs) {
108
+ if (isPrivateIp(a.address)) {
109
+ throw new Error(`blocked private address for ${host}: ${a.address}`);
110
+ }
111
+ }
112
+ }
113
+ catch (e) {
114
+ // Re-throw our own block, but let a DNS failure fall through so fetch()
115
+ // surfaces the real network error instead of a misleading one.
116
+ if (e.message.startsWith('blocked'))
117
+ throw e;
118
+ }
119
+ }
120
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
121
+ // fetch with retries for transient network errors and 5xx responses.
122
+ async function httpFetchWithRetry(url, opts = {}, retries = 2) {
123
+ let lastErr;
124
+ for (let attempt = 0; attempt <= retries; attempt++) {
125
+ try {
126
+ const r = await httpFetch(url, opts);
127
+ if (r.status >= 500 && attempt < retries) {
128
+ await sleep(300 * 2 ** attempt);
129
+ continue;
130
+ }
131
+ return r;
132
+ }
133
+ catch (e) {
134
+ lastErr = e;
135
+ if (attempt < retries) {
136
+ await sleep(300 * 2 ** attempt);
137
+ continue;
138
+ }
139
+ }
140
+ }
141
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
142
+ }
45
143
  // fetch with redirects and a timeout
46
144
  async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {}, } = {}) {
47
145
  const ctrl = new AbortController();
@@ -126,12 +224,13 @@ export function createWebTools() {
126
224
  }
127
225
  const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
128
226
  try {
227
+ await assertPublicUrl(String(url));
129
228
  if (render) {
130
229
  const r = await renderWithHeadless(String(url));
131
230
  const text = htmlToText(r.html);
132
231
  return formatResult(r.status, r.url, text, limit);
133
232
  }
134
- const r = await httpFetch(String(url));
233
+ const r = await httpFetchWithRetry(String(url));
135
234
  // If it's JSON/plain text — return as is
136
235
  if (/application\/json|text\/plain|text\/markdown/i.test(r.contentType)) {
137
236
  const trimmed = r.body.slice(0, limit);
@@ -168,7 +267,7 @@ export function createWebTools() {
168
267
  return 'Error: query is empty.';
169
268
  const limit = Math.min(Math.max(Number(maxResults) || 8, 1), 20);
170
269
  try {
171
- const r = await httpFetch(`https://html.duckduckgo.com/html/?q=${q}`, {
270
+ const r = await httpFetchWithRetry(`https://html.duckduckgo.com/html/?q=${q}`, {
172
271
  timeout: 25_000,
173
272
  headers: {
174
273
  Accept: 'text/html,application/xhtml+xml',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.53.1",
3
+ "version": "2.55.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,7 @@
40
40
  "engines": {
41
41
  "node": ">=20"
42
42
  },
43
+ "packageManager": "npm@11.19.0",
43
44
  "dependencies": {
44
45
  "@modelcontextprotocol/sdk": "^1.30.1",
45
46
  "chalk": "^5.3.0",
package/dist/confirm.js DELETED
@@ -1,105 +0,0 @@
1
- import * as readlinePromises from 'readline/promises';
2
- import { theme } from './theme.js';
3
- import { unifiedDiff, colorDiff } from './diff.js';
4
- import { translate, DEFAULT_LOCALE } from './i18n.js';
5
- export class ConfirmManager {
6
- settings;
7
- alwaysConfirm;
8
- allowedForSession;
9
- locale;
10
- constructor({ config, locale = DEFAULT_LOCALE, } = {}) {
11
- const c = config || {};
12
- this.locale = locale;
13
- this.settings = {
14
- write: c.write !== false,
15
- edit: c.edit !== false,
16
- bash: c.bash !== false,
17
- };
18
- // An invalid operator-supplied regex must NEVER crash the run. Fall back to
19
- // a literal substring match (case-insensitive) for that pattern.
20
- this.alwaysConfirm = (c.alwaysConfirm || []).map((p) => {
21
- try {
22
- return new RegExp(p, 'i');
23
- }
24
- catch {
25
- return new RegExp(escapeRegExp(p), 'i');
26
- }
27
- });
28
- this.allowedForSession = new Set();
29
- }
30
- _matchesAlwaysConfirm(command) {
31
- return this.alwaysConfirm.some((re) => re.test(command));
32
- }
33
- shouldAsk(kind, target = null) {
34
- if (kind === 'bash' && target && this._matchesAlwaysConfirm(target)) {
35
- return true;
36
- }
37
- if (!this.settings[kind])
38
- return false;
39
- if (this.allowedForSession.has(kind))
40
- return false;
41
- return true;
42
- }
43
- // Hot-update the language (from /config lang). The manager holds the locale
44
- // so its prompt follows the interface language without a restart.
45
- setLocale(locale) {
46
- this.locale = locale;
47
- }
48
- async ask({ kind, target, preview = null, message, }) {
49
- if (!this.shouldAsk(kind, target ?? null))
50
- return true;
51
- if (preview) {
52
- console.log(preview);
53
- }
54
- const t = translate(this.locale);
55
- const label = message || t('confirm.ask_label') + ' ' + kind + '?';
56
- const question = theme.warn(`${label} `) +
57
- theme.system('[') +
58
- theme.assistant('y') +
59
- theme.system(' = ' + t('common.yes') + ', ') +
60
- theme.error('n') +
61
- theme.system(' = ' + t('common.no') + ', ') +
62
- theme.user('a') +
63
- theme.system(' = ' + t('confirm.hint') + '] ');
64
- const answer = await this._prompt(question);
65
- if (answer === 'a') {
66
- if (kind !== 'bash' || !this._matchesAlwaysConfirm(target ?? '')) {
67
- this.allowedForSession.add(kind);
68
- }
69
- return true;
70
- }
71
- return answer === 'y';
72
- }
73
- async _prompt(question) {
74
- process.stdin.resume();
75
- if (process.stdin.isTTY && process.stdin.setRawMode) {
76
- process.stdin.setRawMode(false);
77
- }
78
- const rl = readlinePromises.createInterface({
79
- input: process.stdin,
80
- output: process.stdout,
81
- });
82
- try {
83
- const ans = await rl.question(question);
84
- const v = (ans || '').trim().toLowerCase();
85
- if (v === 'a' || v === 'always' || v === 'в')
86
- return 'a';
87
- if (v === 'y' || v === 'yes' || v === 'д' || v === 'да')
88
- return 'y';
89
- return 'n';
90
- }
91
- finally {
92
- rl.close();
93
- }
94
- }
95
- }
96
- // Escape a string so it can be used as a literal regex (used when an
97
- // operator-supplied alwaysConfirm pattern is invalid and we fall back to a
98
- // substring match).
99
- function escapeRegExp(s) {
100
- return s.replace(/[.*+?^${}()|[\]\\]/g, (m) => '\\' + m);
101
- }
102
- export function formatDiffPreview(oldText, newText, { label } = {}) {
103
- const diff = unifiedDiff(oldText, newText, { label });
104
- return colorDiff(diff);
105
- }