zames_pro 2.76.0 → 2.77.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/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.77.0] - 2026-10-17
11
+
12
+ ### Added
13
+
14
+ - **scheduler:** start the first /loop tick immediately
15
+
16
+ ### Fixed
17
+
18
+ - **diff:** show no-changes text instead of the git runner sentinel
19
+ - **config:** refuse prototype-polluting segments in setByPath
20
+ - **net:** read the context counter from history_messages and OpenAI usage
21
+ - **agent-loop:** do not flag a report that explains the work as unfinished
22
+ - **agent-loop:** stop treating prose that mentions invoke/parameter as a tool call
23
+ - **backlog:** ignore headings inside fenced code blocks
24
+ - **subagent:** keep the SubagentStop hook output when the run fails
25
+ - **goal:** parse a markdown-formatted evaluator verdict
26
+ - **qwen:** skip the thinking phase when restoring a chat from content_list
27
+ - **changelog:** keep drafted group headings on their own line
28
+ - **tui:** decode modified and release wheel reports correctly
29
+ - **shell:** normalize an invalid Bash timeout instead of crashing the tool
30
+ - **cost:** keep per-tool stats safe for __proto__/constructor tool names
31
+ - **tui:** mark blocked/aborted tool calls as errors in the result icon
32
+ - **mcp:** do not split a surrogate pair when truncating an MCP result
33
+ - **context:** discover skills at the documented 3 levels deep
34
+ - **commands:** /diffstat accepts --staged like /diff
35
+ - **commands:** /add-dir expands a leading ~ to the home directory
36
+ - **scheduler:** accept the N/step cron form
37
+ - **scheduler:** reject a /loop interval that overflows the 32-bit timer
38
+ - **diagnostics:** never cut the check output inside a surrogate pair
39
+ - **web:** close two SSRF-guard bypasses in WebFetch
40
+ - **scheduler:** flag a job whose next fire could not be computed
41
+ - **subagent:** cap a report without splitting a surrogate pair
42
+ - **qwen:** a retry wait cut short by Esc must stop the retry chain
43
+ - **tui:** a bracketed paste split across reads must not leak its end marker
44
+ - **agent-loop:** do not cut a tool result inside a surrogate pair
45
+ - **web:** a broken numeric HTML entity must not fail the whole WebFetch
46
+ - **scheduler:** keep a scheduled message's jobId through the queue merge; explain an unrecognized /loop interval
47
+ - **tui:** localize the Ink dialog key-hint bars
48
+ - **i18n:** drop the dead /resume <n> hint from /sessions and the docs
49
+
10
50
  ## [2.76.0] - 2026-10-16
11
51
 
12
52
  ### Added
package/README.md CHANGED
@@ -374,8 +374,8 @@ Codex CLI:
374
374
  runtime.
375
375
  - /add-dir <path> — validate an extra directory (the sandbox is fixed at
376
376
  startup; relaunch with --dir to write there).
377
- - /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
378
- dialogue into the terminal, so the restored context is visible. Only the
377
+ - /resume-id <id> (or picking a chat in the /chats list) — open a chat and
378
+ PRINT its dialogue into the terminal, so the restored context is visible. Only the
379
379
  last 20 messages are shown (`RESTORED_HISTORY_LIMIT`). /last reopens the last
380
380
  chat of the current directory with no lookup step; /sessions shows the saved
381
381
  sessions with their relative age.
@@ -400,7 +400,9 @@ Codex CLI:
400
400
  tests`). /cron "<min> <hour> <dom> <month> <dow>" <task> — run on a schedule.
401
401
  /jobs [rm <id>|clear] lists and stops them. A fired job is put into the same
402
402
  message queue you type into, so it runs when the agent is free (never mid-
403
- generation) and still respects the send throttle.
403
+ generation) and still respects the send throttle. The FIRST run of a `/loop`
404
+ is due at once (and runs as soon as the agent is idle); a `/cron` job waits
405
+ for its schedule.
404
406
  - /thinking [on|off] and /web [on|off] — toggle the reasoning / web-search
405
407
  behaviour. On **DeepSeek** these are the "Deep thinking" / "Smart search"
406
408
  toggles; on **Qwen** they drive its thinking MODE (Thinking / Fast) and the
@@ -959,8 +961,8 @@ zames --task "..." --output-format jsonl \
959
961
  модель Qwen на лету.
960
962
  - /add-dir <path> — проверить дополнительную директорию (песочница
961
963
  фиксируется при старте; для записи туда перезапустите с --dir).
962
- - /resume <n> (после /chats) и /resume-id <id> — открыть чат и НАПЕЧАТАТЬ его
963
- диалог в терминал, чтобы восстановленный контекст был виден. Показываются
964
+ - /resume-id <id> (или выбор чата в списке /chats) — открыть чат и
965
+ НАПЕЧАТАТЬ его диалог в терминал, чтобы восстановленный контекст был виден. Показываются
964
966
  только последние 20 сообщений (`RESTORED_HISTORY_LIMIT`). /last открывает
965
967
  последний чат текущей директории без поиска; /sessions показывает сохранённые
966
968
  сессии с их относительным возрастом.
@@ -987,7 +989,8 @@ zames --task "..." --output-format jsonl \
987
989
  запуск по расписанию. /jobs [rm <id>|clear] перечисляет и останавливает их.
988
990
  Сработавшая задача попадает в ту же очередь сообщений, что и ваш ввод, так
989
991
  что выполняется, когда агент свободен (никогда посреди генерации), и всё
990
- равно соблюдает троттлинг отправки.
992
+ равно соблюдает троттлинг отправки. ПЕРВЫЙ запуск `/loop` — сразу же (как
993
+ только агент свободен); задача `/cron` ждёт своего расписания.
991
994
  - /thinking [on|off] и /web [on|off] — переключить режим размышления /
992
995
  веб-поиска. В **DeepSeek** это тумблеры «Deep thinking» / «Smart search»;
993
996
  в **Qwen** — это режим размышления (Thinking / Fast) и пункт «Web search»
@@ -25,6 +25,14 @@ export function truncateToolResult(text, limit) {
25
25
  // lines — a cut that EXCLUDED the newline left a half line before the marker.
26
26
  if (nl >= searchFrom)
27
27
  cut = nl + 1;
28
+ // Never split a surrogate PAIR (an emoji / astral CJK char): the high half
29
+ // alone is a lone surrogate — a broken code point some channels replace with
30
+ // '?' or truncate at. Step back one unit so the pair is dropped WHOLE.
31
+ else {
32
+ const c = text.charCodeAt(cut - 1);
33
+ if (c >= 0xd800 && c <= 0xdbff)
34
+ cut--;
35
+ }
28
36
  const omitted = text.length - cut;
29
37
  return text.slice(0, cut) + NL + `[...truncated ${omitted} chars]`;
30
38
  }
@@ -37,8 +45,13 @@ export function responseLooksLikeToolCall(rawResponse) {
37
45
  const raw = rawResponse || '';
38
46
  return (
39
47
  // Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
40
- // function_call, etc.
41
- /("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
48
+ // function_call, etc. The BARE words `invoke`/`parameter` are deliberately
49
+ // NOT markers on their own: an ordinary final report can use them as
50
+ // English words ("a timeout parameter: …", "the invoke helper …"), and a
51
+ // false positive rejected a perfectly good answer and re-asked the model
52
+ // until the retry budget ended the task with an error. A real XML/DSML
53
+ // call is still caught by the tag pattern below.
54
+ /("tool"\s*:|\btool_calls?\b|DSML|function_call)/i.test(raw) ||
42
55
  // "tool" without an opening quote/bracket, with a junk prefix
43
56
  // (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
44
57
  /(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
@@ -98,7 +111,10 @@ export function looksLikeUnfinishedWork(text) {
98
111
  if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
99
112
  return false;
100
113
  }
101
- const en = /\b(now|next|then|let me|let's|i will|i'll|i am going to|i'm going to|going to|about to|will now|time to)\b[^.!?\n]{0,120}\b(update|write|edit|read|run|check|add|fix|create|remove|delete|apply|test|commit|push|install|open|search|look|verify|change|modify|implement|review)\b/i;
114
+ // `update`/`summarize`/`explain` are REPORT verbs (`I will update you on the
115
+ // results: …`) — they do not promise a tool call. A colon anywhere in the
116
+ // gap also means the sentence is explaining a fact, not about to act.
117
+ const en = /\b(now|next|then|let me|let's|i will|i'll|i am going to|i'm going to|going to|about to|will now|time to)\b[^.!?\n:]{0,120}\b(write|edit|read|run|check|add|fix|create|remove|delete|apply|test|commit|push|install|open|search|look|verify|change|modify|implement|review)\b/i;
102
118
  const ru = /(^|[^а-яё])(проверю|обновлю|исправлю|добавлю|запущу|выполню|посмотрю|прочитаю|изменю|попробую|сделаю)([^а-яё]|$)/i;
103
119
  return en.test(t) || ru.test(t);
104
120
  }
package/dist/changelog.js CHANGED
@@ -99,6 +99,12 @@ export function syncUnreleased(changelog, body) {
99
99
  const tail = next < 0 ? '' : rest.slice(next);
100
100
  const cleanBody = String(body || '')
101
101
  .replace(/^## \[Unreleased\]\s*/m, '')
102
+ // A drafted group heading must START a line of its own. When the draft
103
+ // carries several groups (`## Added` … `## Fixed`) without a blank line
104
+ // between them, the next group used to stay glued to the last bullet
105
+ // (`- a### Fixed`) — it was not a heading at all, so the item vanished
106
+ // from the rendered changelog.
107
+ .replace(/([^\n])\n?(?=## )/g, '$1' + nl + nl)
102
108
  .trim()
103
109
  // The draft renders groups as `## Added` (top-level), but INSIDE the file
104
110
  // a version's groups are `### Added` (a subsection of the version heading).
package/dist/commands.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { providerLabel } from './providers.js';
2
2
  import path from 'path';
3
+ import os from 'os';
3
4
  import { isImageName } from './attachments.js';
4
5
  import { translate } from './i18n.js';
5
6
  // Helpers for the extra slash commands (/diff, /cost, /export, /doctor,
@@ -14,8 +15,11 @@ export function formatDiff(diffText, opts = {}, t = translate('en')) {
14
15
  .split(CR + NL)
15
16
  .join(NL)
16
17
  .trimEnd();
17
- if (!trimmed)
18
+ // The shared git runner reports an EMPTY result as this sentinel, so a clean
19
+ // tree must not print it to the operator (formatDiffStat already handles it).
20
+ if (!trimmed || trimmed.trim() === '(command produced no output)') {
18
21
  return t('diff.no_changes');
22
+ }
19
23
  const lines = trimmed.split(NL);
20
24
  if (lines.length <= maxLines)
21
25
  return trimmed;
@@ -28,6 +32,12 @@ export function formatDiff(diffText, opts = {}, t = translate('en')) {
28
32
  export function diffGitArgs(staged = false) {
29
33
  return staged ? ['diff', '--staged'] : ['diff'];
30
34
  }
35
+ // Git arguments for `/diffstat`. Same shape as diffGitArgs() so the two
36
+ // commands stay symmetric: `/diffstat --staged` used to be impossible (the
37
+ // command hardcoded `git diff --stat`), even though `/diff --staged` worked.
38
+ export function diffStatGitArgs(staged = false) {
39
+ return staged ? ['diff', '--staged', '--stat'] : ['diff', '--stat'];
40
+ }
31
41
  // ---------- /diffstat ----------
32
42
  /** Render `git diff --stat` output (a compact change summary) for display. */
33
43
  export function formatDiffStat(statText, opts = {}, t = translate('en')) {
@@ -84,9 +94,15 @@ export function summarizeTranscript(entries) {
84
94
  const stats = {
85
95
  turns: 0,
86
96
  toolCalls: 0,
87
- toolCounts: {},
97
+ // Null-prototype maps: a tool NAME is arbitrary text (an MCP server can
98
+ // advertise anything, the model may emit any name). With a plain {} a name
99
+ // like '__proto__' assigns the PROTOTYPE (the tool vanishes from the count
100
+ // and Object.prototype gets mutated), and 'constructor'/'toString' read an
101
+ // inherited function instead of a number — /cost then printed
102
+ // "constructor: function Object() { [native code] }1".
103
+ toolCounts: Object.create(null),
88
104
  toolMs: 0,
89
- toolTime: {},
105
+ toolTime: Object.create(null),
90
106
  durationMs: 0,
91
107
  startedAt: null,
92
108
  autoCompacts: 0,
@@ -332,9 +348,18 @@ export function renderDoctor(d, t = translate('en')) {
332
348
  }
333
349
  // ---------- /add-dir ----------
334
350
  export function resolveExtraDir(input, workdir, t = translate('en')) {
335
- const raw = String(input ?? '').trim();
351
+ let raw = String(input ?? '').trim();
336
352
  if (!raw)
337
353
  return { error: t('adddir.usage') };
354
+ // A leading `~` means the home directory (a shell convention the operator
355
+ // expects). Without expansion resolve() treated it as a literal directory
356
+ // named `~` under the working dir, so /add-dir ~ reported "No such
357
+ // directory: /w/~". Only a LEADING `~` / `~/` expands; a `~` elsewhere is
358
+ // an ordinary character.
359
+ if (raw === '~')
360
+ raw = os.homedir();
361
+ else if (raw.startsWith('~/') || raw.startsWith('~' + path.sep))
362
+ raw = path.join(os.homedir(), raw.slice(2));
338
363
  const abs = path.resolve(workdir, raw);
339
364
  if (abs === path.resolve(workdir)) {
340
365
  return { error: t('adddir.same') };
@@ -828,6 +853,7 @@ export function mergeMessages(msgs) {
828
853
  return {
829
854
  text: list[0].text,
830
855
  attachments: (list[0].attachments || []).slice(),
856
+ jobId: list[0].jobId,
831
857
  };
832
858
  }
833
859
  let imgOffset = 0;
@@ -861,7 +887,10 @@ export function mergeMessages(msgs) {
861
887
  ' messages while you were working. They are listed below in order — ' +
862
888
  'treat them as one instruction.';
863
889
  const text = header + NL + NL + parts.join(NL + NL + '---' + NL + NL);
864
- return { text, attachments };
890
+ // Keep the FIRST scheduled job id in the batch so the ticker still knows a
891
+ // copy of that job is in flight (see MergedMessage.jobId).
892
+ const jobId = list.find((m) => m.jobId !== undefined)?.jobId;
893
+ return { text, attachments, jobId };
865
894
  }
866
895
  // Parse BACKLOG.md into actionable items. Recognizes headings of the form
867
896
  // `### A1. ...`, `### D7. ...` and marks an item done when its heading carries
@@ -870,8 +899,24 @@ export function mergeMessages(msgs) {
870
899
  export function parseBacklogItems(text) {
871
900
  const out = [];
872
901
  let priority = '';
902
+ // A fenced code block in an item's body may contain a line that LOOKS like a
903
+ // heading (an example of the format, a pasted log). Those are not headings:
904
+ // counting them invented a phantom P0 item that `/improve` picked over the
905
+ // real backlog. Track the fence (``` or ~~~, at least three) and skip.
906
+ let fence = '';
873
907
  for (const raw of String(text ?? '').split(NL)) {
874
908
  const line = raw.trimEnd();
909
+ const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);
910
+ if (fenceMatch) {
911
+ const mark = fenceMatch[1][0];
912
+ if (!fence)
913
+ fence = mark;
914
+ else if (mark === fence)
915
+ fence = '';
916
+ continue;
917
+ }
918
+ if (fence)
919
+ continue;
875
920
  const p = /^##\s+(P[0-3])\b/.exec(line);
876
921
  if (p) {
877
922
  priority = p[1];
package/dist/config.js CHANGED
@@ -498,8 +498,15 @@ export function getByPath(obj, path) {
498
498
  }
499
499
  return cur;
500
500
  }
501
+ // Object keys that must NEVER be walked/assigned by a dotted path: they reach
502
+ // the PROTOTYPE chain. deepMerge() already skips them; setByPath did not, so a
503
+ // `__proto__.x` segment polluted Object.prototype for the whole process (and
504
+ // wrote the injected keys into the config file).
505
+ const UNSAFE_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
501
506
  export function setByPath(obj, path, value) {
502
507
  const parts = path.split('.');
508
+ if (parts.some((p) => UNSAFE_PATH_KEYS.has(p)))
509
+ return;
503
510
  let cur = obj;
504
511
  for (let i = 0; i < parts.length - 1; i++) {
505
512
  const p = parts[i];
package/dist/context.js CHANGED
@@ -340,7 +340,11 @@ async function findSkillFiles(root, depth = 3) {
340
340
  await walk(full, level + 1);
341
341
  }
342
342
  };
343
- await walk(root, 1);
343
+ // Start at level 0 so `level < depth` admits exactly `depth` levels of
344
+ // subdirectories: with walk(root, 1) the FIRST level counted as 1 and the
345
+ // walk stopped one level early, so a skill at the documented "3 levels
346
+ // deep" (`.zames/skills/<a>/<b>/<skill>/SKILL.md`) was never discovered.
347
+ await walk(root, 0);
344
348
  return out;
345
349
  }
346
350
  export async function loadSkills(workdir) {
@@ -35,7 +35,14 @@ export function summarizeDiagnostics(raw, maxChars) {
35
35
  const chosen = errLines.length ? errLines : lines;
36
36
  let out = chosen.join('\n');
37
37
  if (out.length > maxChars) {
38
- out = out.slice(0, maxChars) + '\n… [diagnostics truncated]';
38
+ // Never split a surrogate PAIR (emoji / astral CJK): the high half alone
39
+ // is a lone surrogate in the model's context. Same rule as
40
+ // truncateToolResult (agent-loop) and capReport (subagent).
41
+ let cut = maxChars;
42
+ const c = out.charCodeAt(cut - 1);
43
+ if (c >= 0xd800 && c <= 0xdbff)
44
+ cut--;
45
+ out = out.slice(0, cut) + '\n… [diagnostics truncated]';
39
46
  }
40
47
  return out;
41
48
  }
package/dist/goal.js CHANGED
@@ -70,12 +70,21 @@ function argumentReason(reason) {
70
70
  // "continue", never as "done").
71
71
  export function parseGoalVerdict(text) {
72
72
  const t = String(text ?? '');
73
- const m = /VERDICT:\s*(`{0,3})?\s*(MET|NOT_MET|IMPOSSIBLE)/i.exec(t);
73
+ // The label may be bolded/italicised and the colon may carry surrounding
74
+ // spaces (`**VERDICT:** MET`) — a markdown-model answer did exactly that and
75
+ // the strict `VERDICT:` matcher returned null, so the goal never parsed and
76
+ // the loop auto-continued until the turn budget.
77
+ const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(MET|NOT_MET|IMPOSSIBLE)/i.exec(t);
74
78
  if (!m)
75
79
  return null;
76
- const raw = m[2].toUpperCase();
80
+ const raw = m[1].toUpperCase();
77
81
  const status = raw === 'MET' ? 'met' : raw === 'IMPOSSIBLE' ? 'impossible' : 'not_met';
78
- const rm = /REASON:\s*(.+)/i.exec(t);
79
- const reason = rm ? rm[1].replace(/`{2,}/g, '').trim() : '';
82
+ const rm = /REASON\s*[*_`]*\s*:\s*(.+)/i.exec(t);
83
+ const reason = rm
84
+ ? rm[1]
85
+ .replace(/`{1,3}/g, '')
86
+ .replace(/\*{1,2}/g, '')
87
+ .trim()
88
+ : '';
80
89
  return { status, reason };
81
90
  }
@@ -114,6 +114,19 @@ export const deepseekMessages = {
114
114
  'auth.form.hint_field': { ru: 'поле', en: 'field' },
115
115
  'auth.form.hint_submit': { ru: 'войти', en: 'sign in' },
116
116
  'auth.form.hint_cancel': { ru: 'отмена', en: 'cancel' },
117
+ // Key-hint labels of the Ink dialogs (SelectList / ConfigMenu /
118
+ // ConfirmDialog). They used to be hardcoded Russian, so an English UI
119
+ // still showed a Russian hint bar.
120
+ 'tui.hint.select': { ru: 'выбор', en: 'select' },
121
+ 'tui.hint.edit': { ru: 'правка', en: 'edit' },
122
+ 'tui.hint.ok': { ru: 'ок', en: 'ok' },
123
+ 'tui.hint.cancel': { ru: 'отмена', en: 'cancel' },
124
+ 'tui.hint.search': { ru: 'поиск', en: 'search' },
125
+ 'tui.hint.reset': { ru: 'сброс', en: 'reset' },
126
+ 'tui.hint.quit': { ru: 'выход', en: 'quit' },
127
+ 'tui.hint.change': { ru: 'изменить', en: 'change' },
128
+ // Marks the CURRENT entry in a list menu (e.g. the active Qwen model).
129
+ 'tui.list.current': { ru: '← текущая', en: '← current' },
117
130
  'auth.form.note_saved': {
118
131
  ru: 'Enter — использовать сохранённые логин и пароль.',
119
132
  en: 'Enter — use the saved login and password.',
package/dist/i18n/help.js CHANGED
@@ -330,6 +330,10 @@ export const helpMessages = {
330
330
  ru: 'Использование: /cron "<мин> <час> <день> <мес> <день недели>" <задача>',
331
331
  en: 'Usage: /cron "<min> <hour> <dom> <month> <dow>" <task>',
332
332
  },
333
+ 'sched.interval_typo': {
334
+ ru: 'Интервал «{v}» не распознан — задача НЕ добавлена. Единицы: s/sec, m/min, h (например, /loop 10m проверь тесты). Без единицы — СЕКУНДЫ (120 = 120с), для минут нужен суффикс m.',
335
+ en: 'Interval "{v}" was not recognized — NO job was added. Units: s/sec, m/min, h (e.g. /loop 10m run the tests). Without a unit it means SECONDS (120 = 120s); use the m suffix for minutes.',
336
+ },
333
337
  'sched.loop_too_short': {
334
338
  ru: 'Слишком часто: минимум {min} (троттлинг отправок 15с).',
335
339
  en: 'Too frequent: minimum {min} (the send throttle is 15s).',
@@ -338,6 +342,10 @@ export const helpMessages = {
338
342
  ru: '⏰ Задача #{id} добавлена ({when}): {task}',
339
343
  en: '⏰ Job #{id} added ({when}): {task}',
340
344
  },
345
+ 'sched.added_hint': {
346
+ ru: 'Если агент свободен — запуск сразу, иначе сразу после текущей задачи; дальше — по расписанию. Список — /jobs, удалить — /jobs rm <id>.',
347
+ en: 'Runs at once if the agent is idle, otherwise right after the current task; then on schedule. List: /jobs, remove: /jobs rm <id>.',
348
+ },
341
349
  'sched.title': { ru: 'Запланированные задачи:', en: 'Scheduled jobs:' },
342
350
  'sched.none': { ru: 'Запланированных задач нет.', en: 'No scheduled jobs.' },
343
351
  'sched.fired': {
@@ -171,8 +171,8 @@ export const selfReviewMessages = {
171
171
  en: 'No saved sessions. They appear after the first task/chat.',
172
172
  },
173
173
  'sessions.restore_hint': {
174
- ru: 'Восстановить: /resume-id <id> (полный id) или /resume <n> после /chats.',
175
- en: 'Restore: /resume-id <id> (full id) or /resume <n> after /chats.',
174
+ ru: 'Восстановить: /resume-id <id> (полный id) или выбери сессию в списке /sessions.',
175
+ en: 'Restore: /resume-id <id> (full id) or pick a session from the /sessions list.',
176
176
  },
177
177
  'sessions.pick_title': {
178
178
  ru: 'Выберите сессию (↑/↓, Enter — открыть, Esc — отмена)',
package/dist/index.js CHANGED
@@ -1108,7 +1108,7 @@ async function main() {
1108
1108
  if (editor)
1109
1109
  editor.pause();
1110
1110
  try {
1111
- return await askOperatorConfirm(null, t('perm.ask_sub', { tool: info.tool, reason: info.reason }));
1111
+ return await askOperatorConfirm(null, t('perm.ask_sub', { tool: info.tool, reason: info.reason }), t, currentLocale);
1112
1112
  }
1113
1113
  finally {
1114
1114
  if (editor)
@@ -1910,6 +1910,7 @@ async function main() {
1910
1910
  getDefault: (path) => getByPath(DEFAULTS, path),
1911
1911
  set: (field, raw) => configSetRaw(field, raw),
1912
1912
  reset: (field) => configResetField(field),
1913
+ locale: currentLocale,
1913
1914
  };
1914
1915
  try {
1915
1916
  // Prefer the Ink menu (nicer, resize-aware) when the TUI is enabled;
@@ -1990,6 +1991,7 @@ async function main() {
1990
1991
  const { selectListInk } = await import('./tui/run-select.js');
1991
1992
  picked = await selectListInk({
1992
1993
  title: t('chats.pick_title'),
1994
+ locale: currentLocale,
1993
1995
  items: chats.map((c, i) => ({
1994
1996
  value: c.id,
1995
1997
  label: String(i + 1).padStart(2, ' ') + '. ' + c.title,
@@ -2023,6 +2025,7 @@ async function main() {
2023
2025
  const { selectListInk } = await import('./tui/run-select.js');
2024
2026
  picked = await selectListInk({
2025
2027
  title: t('sessions.pick_title'),
2028
+ locale: currentLocale,
2026
2029
  items: sessions.map((s, i) => ({
2027
2030
  value: s.id,
2028
2031
  label: String(i + 1).padStart(2, ' ') +
@@ -2591,8 +2594,9 @@ async function main() {
2591
2594
  items: models.map((m) => ({
2592
2595
  value: m.id,
2593
2596
  label: m.name || m.id,
2594
- detail: m.id === config.qwen.model ? '← текущая' : m.id,
2597
+ detail: m.id === config.qwen.model ? t('tui.list.current') : m.id,
2595
2598
  })),
2599
+ locale: currentLocale,
2596
2600
  });
2597
2601
  }
2598
2602
  catch {
@@ -2784,6 +2788,13 @@ async function main() {
2784
2788
  const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
2785
2789
  const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
2786
2790
  const ms = parseInterval(intervalStr);
2791
+ if (!ms && intervalStr) {
2792
+ // Say WHY the job was not added. A bare `10` is not a typo the agent
2793
+ // can spot: it IS a valid interval (10 SECONDS), while an operator who
2794
+ // meant ten minutes needs the `m` suffix.
2795
+ console.error(theme.error(t('sched.interval_typo', { v: intervalStr })));
2796
+ continue;
2797
+ }
2787
2798
  if (!ms || !taskStr) {
2788
2799
  console.error(theme.error(t('sched.loop_usage')));
2789
2800
  continue;
@@ -2804,6 +2815,12 @@ async function main() {
2804
2815
  when: formatInterval(ms),
2805
2816
  task: taskStr,
2806
2817
  })));
2818
+ // Tell the operator WHEN it will actually run. A scheduled task never
2819
+ // interrupts a running turn: it is queued and starts only when the
2820
+ // agent is idle (or right after the current task). The bare "added"
2821
+ // line made a first /loop look like a no-op, so the operator re-sent the
2822
+ // task by hand (the very report this line answers).
2823
+ console.log(theme.dim(t('sched.added_hint')));
2807
2824
  continue;
2808
2825
  }
2809
2826
  if (lower === '/cron' || lower.startsWith('/cron ')) {
@@ -2833,6 +2850,7 @@ async function main() {
2833
2850
  when: cronExpr + ' (in ~' + nextIn + ')',
2834
2851
  task: taskStr,
2835
2852
  })));
2853
+ console.log(theme.dim(t('sched.added_hint')));
2836
2854
  continue;
2837
2855
  }
2838
2856
  if (lower === '/jobs' || lower.startsWith('/jobs ')) {
@@ -3298,14 +3316,15 @@ async function main() {
3298
3316
  console.log(theme.system(mod.commands.formatDiff(out, { maxLines: 400 }, t)));
3299
3317
  continue;
3300
3318
  }
3301
- if (lower === '/diffstat') {
3319
+ if (lower === '/diffstat' || lower.startsWith('/diffstat ')) {
3320
+ const staged = lower.indexOf('--staged') !== -1;
3302
3321
  const { runGitArgs } = await import('./gitTools.js');
3303
3322
  const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
3304
3323
  if (probe.trim() !== 'true') {
3305
3324
  console.error(theme.error(t('diff.not_repo')));
3306
3325
  continue;
3307
3326
  }
3308
- const out = await runGitArgs(['diff', '--stat'], currentWorkdir, 20_000);
3327
+ const out = await runGitArgs(mod.commands.diffStatGitArgs(staged), currentWorkdir, 20_000);
3309
3328
  console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
3310
3329
  continue;
3311
3330
  }
package/dist/input.js CHANGED
@@ -1601,7 +1601,13 @@ export class LineEditor {
1601
1601
  // A chunk may end in the MIDDLE of a PASTE marker (`ESC[2`, `ESC[20`, …).
1602
1602
  // Hold that tail back so the next chunk completes it; otherwise the
1603
1603
  // fragment is inserted as literal text and the paste is not bracketed.
1604
- const held = splitPasteTail(s);
1604
+ // EXCEPTION: once inside a bracketed paste the body is appended VERBATIM
1605
+ // to _pasteBuf (it is not parsed by this loop), so a held tail would be
1606
+ // re-attached to `s` and inserted as text BEFORE the body — the tail
1607
+ // (`1~`) leaked into the input line and was not part of the pasted text.
1608
+ const held = this._inPaste
1609
+ ? { head: s, tail: null }
1610
+ : splitPasteTail(s);
1605
1611
  if (held.tail) {
1606
1612
  this._ctrlTail = held.tail;
1607
1613
  s = held.head;
package/dist/mcp.js CHANGED
@@ -183,7 +183,15 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
183
183
  const str = String(s);
184
184
  if (str.length <= limit)
185
185
  return str;
186
- const head = str.slice(0, limit);
186
+ let head = str.slice(0, limit);
187
+ // Never leave a lone HIGH surrogate at the cut: an emoji / astral CJK char
188
+ // sits on two UTF-16 units, and cutting between them hands the model a
189
+ // broken code point (same rule as truncateToolResult / capReport).
190
+ if (head.length > 0) {
191
+ const last = head.charCodeAt(head.length - 1);
192
+ if (last >= 0xd800 && last <= 0xdbff)
193
+ head = head.slice(0, -1);
194
+ }
187
195
  // Prefer a line boundary so a cut does not split a line in half.
188
196
  const nl = head.lastIndexOf(String.fromCharCode(10));
189
197
  const cut = nl > limit * 0.5 ? head.slice(0, nl + 1) : head;
@@ -280,6 +280,43 @@ export function extractTokenUsage(body) {
280
280
  }
281
281
  }
282
282
  }
283
+ // An OpenAI-compatible body reports the turn size as usage.total_tokens.
284
+ const usage = o.usage;
285
+ if (usage && typeof usage === 'object') {
286
+ consider(usage.total_tokens);
287
+ }
288
+ }
289
+ if (found !== null)
290
+ return found;
291
+ // A PLAIN JSON body (not SSE): /api/v0/chat/history_messages answers this
292
+ // way, and every message carries the running counter. Without this a
293
+ // resumed chat showed no context size until the next completion. The
294
+ // payload is the whole history, so the LAST value wins.
295
+ try {
296
+ const root = JSON.parse(body);
297
+ const visit = (node) => {
298
+ if (node == null || typeof node !== 'object')
299
+ return;
300
+ if (Array.isArray(node)) {
301
+ for (const it of node)
302
+ visit(it);
303
+ return;
304
+ }
305
+ const o = node;
306
+ consider(o.accumulated_token_usage);
307
+ const usage = o.usage;
308
+ if (usage && typeof usage === 'object')
309
+ consider(usage.total_tokens);
310
+ for (const key of Object.keys(o)) {
311
+ const v = o[key];
312
+ if (v && typeof v === 'object')
313
+ visit(v);
314
+ }
315
+ };
316
+ visit(root);
317
+ }
318
+ catch {
319
+ // not plain JSON — nothing more to read
283
320
  }
284
321
  return found;
285
322
  }
@@ -1396,7 +1396,8 @@ export class QwenBrowser {
1396
1396
  attempt: rateLimitRetries,
1397
1397
  max: this.maxRateLimitRetries,
1398
1398
  }));
1399
- await this._sleepInterruptible(this.rateLimitWaitMs);
1399
+ if (await this._sleepInterruptible(this.rateLimitWaitMs))
1400
+ return '(прервано пользователем)';
1400
1401
  continue;
1401
1402
  }
1402
1403
  if (e instanceof QwenServerBusyError || /server busy/i.test(msg)) {
@@ -1408,7 +1409,8 @@ export class QwenBrowser {
1408
1409
  attempt: serverBusyRetries,
1409
1410
  max: this.maxServerBusyRetries,
1410
1411
  }));
1411
- await this._sleepInterruptible(this.serverBusyWaitMs);
1412
+ if (await this._sleepInterruptible(this.serverBusyWaitMs))
1413
+ return '(прервано пользователем)';
1412
1414
  continue;
1413
1415
  }
1414
1416
  if (e instanceof QwenIncompleteError) {
@@ -1419,7 +1421,8 @@ export class QwenBrowser {
1419
1421
  attempt: incompleteRetries,
1420
1422
  max: this.maxIncompleteRetries,
1421
1423
  })));
1422
- await this._sleepInterruptible(this.incompleteWaitMs);
1424
+ if (await this._sleepInterruptible(this.incompleteWaitMs))
1425
+ return '(прервано пользователем)';
1423
1426
  continue;
1424
1427
  }
1425
1428
  // A LOST page/context ("Target page, context or browser has been
@@ -186,10 +186,17 @@ export function parseQwenHistory(json) {
186
186
  }
187
187
  }
188
188
  // `content_list` (phase-split output) is used when `content` is empty.
189
+ // Each part carries its PHASE, and the reasoning phase must never leak
190
+ // into the restored dialogue as the answer (the live SSE parser skips
191
+ // `thinking_summary` for the same reason).
189
192
  if (!text && Array.isArray(m.content_list)) {
190
193
  for (const part of m.content_list) {
191
- if (part && typeof part.content === 'string')
192
- text += part.content;
194
+ if (!part || typeof part.content !== 'string')
195
+ continue;
196
+ const phase = String(part.phase ?? part.type ?? '');
197
+ if (/think|reason/i.test(phase))
198
+ continue;
199
+ text += part.content;
193
200
  }
194
201
  }
195
202
  // `reasoning_content` is the THINK phase and must never leak into the
package/dist/run-task.js CHANGED
@@ -2,12 +2,15 @@ import { theme } from './theme.js';
2
2
  import { withGoal, mergeMessages, isSlashCommand, formatDuration, } from './commands.js';
3
3
  import { buildEvaluatorPrompt, buildContinuationDirective, parseGoalVerdict, GOAL_MAX_TURNS, } from './goal.js';
4
4
  import { formatCompactTokens, pasteReplacement, expandPastes } from './input.js';
5
+ import { DEFAULT_LOCALE } from './i18n.js';
5
6
  import { providerAskDeadlineMs } from './config.js';
6
7
  // Ask the operator to approve a tool call flagged by an `ask` permission rule
7
8
  // (C1). While the LineEditor owns the terminal it must be paused first, or the
8
9
  // confirmation prompt and the input line fight for the same keys. A non-TTY run
9
10
  // has no way to answer, so it DENIES (never silently allows a guarded call).
10
- export async function askOperatorConfirm(editor, question, t) {
11
+ export async function askOperatorConfirm(editor, question, t,
12
+ /** Language of the Ink dialog's key-hint bar (the labels use `t`). */
13
+ locale = DEFAULT_LOCALE) {
11
14
  if (!(process.stdin.isTTY && process.stdout.isTTY))
12
15
  return false;
13
16
  // Prefer the Ink modal when the TUI owns the terminal: it keeps the layout
@@ -23,6 +26,9 @@ export async function askOperatorConfirm(editor, question, t) {
23
26
  message: question,
24
27
  yesLabel: tr('confirm.yes'),
25
28
  noLabel: tr('confirm.no'),
29
+ // The dialog's key-hint bar is operator text: it must follow the SAME
30
+ // language as the labels above, not a hardcoded one.
31
+ locale,
26
32
  });
27
33
  }
28
34
  finally {
@@ -386,7 +392,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
386
392
  // C1: a rule with action "ask". Show the reason and wait for the
387
393
  // operator. In non-TTY this denies.
388
394
  ui.warning(t('perm.ask', { tool: info.tool, reason: info.reason }));
389
- const ok = await askOperatorConfirm(editor || null, t('perm.confirm'), t);
395
+ const ok = await askOperatorConfirm(editor || null, t('perm.confirm'), t, locale);
390
396
  ui.warning(ok ? t('perm.allowed') : t('perm.denied'));
391
397
  transcript?.log('permission_ask', {
392
398
  tool: info.tool,
package/dist/scheduler.js CHANGED
@@ -16,8 +16,17 @@ export function parseInterval(text) {
16
16
  : unit.startsWith('m')
17
17
  ? 60_000
18
18
  : 1000;
19
- return n * mult;
19
+ const ms = n * mult;
20
+ // setInterval clamps its delay to a 32-bit signed int (~24.8 days) and Node
21
+ // warns on every tick, while the scheduler computes a fire time the timer
22
+ // can never reach — a huge value is a typo, not a schedule. Reject it here
23
+ // so the operator gets the usage/typo message instead of a silent dead job.
24
+ if (ms > MAX_TIMER_MS)
25
+ return null;
26
+ return ms;
20
27
  }
28
+ /** Largest delay setInterval accepts (32-bit signed milliseconds). */
29
+ export const MAX_TIMER_MS = 2_147_483_647;
21
30
  // Render an interval back to the shortest human form (used in listings).
22
31
  // Non-finite input (a cron job whose next fire could not be computed sets
23
32
  // nextAt to Infinity) renders as "-" instead of "Infinityh".
@@ -53,7 +62,10 @@ function parseCronField(field, min, max) {
53
62
  }
54
63
  else {
55
64
  lo = Number(range);
56
- hi = lo;
65
+ // `N/step` (a start with a step, no range) is valid cron: it means from
66
+ // N up to the field maximum. Treating it as the single value N made a
67
+ // normal `5/20 * * * *` expression invalid.
68
+ hi = stepStr === undefined ? lo : max;
57
69
  }
58
70
  if (!Number.isInteger(lo) || !Number.isInteger(hi))
59
71
  return null;
@@ -128,6 +140,13 @@ export function formatJobLine(job, now) {
128
140
  const when = job.kind === 'loop'
129
141
  ? 'every ' + formatInterval(job.intervalMs || 0)
130
142
  : job.cron || '';
143
+ // A cron job whose next fire could not be computed stores Infinity (see
144
+ // due()). "next ~-" (formatInterval maps a non-finite value to '-') read
145
+ // like a normal schedule, so the job looked healthy while it could never
146
+ // fire again.
147
+ if (!Number.isFinite(job.nextAt)) {
148
+ return ' #' + job.id + ' [' + when + ', NEVER fires again] ' + job.task;
149
+ }
131
150
  const nextIn = formatInterval(Math.max(0, job.nextAt - now));
132
151
  return ' #' + job.id + ' [' + when + ', next ~' + nextIn + '] ' + job.task;
133
152
  }
@@ -142,7 +161,13 @@ export class Scheduler {
142
161
  kind: 'loop',
143
162
  task,
144
163
  intervalMs,
145
- nextAt: now + intervalMs,
164
+ // The FIRST run is due at once: `/loop 30m <task>` means "start now,
165
+ // then repeat every 30m". Scheduling the first fire one full interval
166
+ // ahead left the operator staring at half an hour of silence right
167
+ // after the command — it read as "the loop did nothing". `due()`
168
+ // reschedules the job a full interval ahead AFTER it fires, so this
169
+ // does not become a busy loop.
170
+ nextAt: now,
146
171
  createdAt: now,
147
172
  };
148
173
  this.jobs.push(job);
package/dist/shell.js CHANGED
@@ -84,10 +84,18 @@ function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
84
84
  return parts.join('\n');
85
85
  }
86
86
  export function runShell(workdir, command, timeout = 30_000, signal) {
87
+ // The timeout arrives from the MODEL's tool arguments, and exec validates it
88
+ // SYNCHRONOUSLY: a negative, fractional or non-numeric value throws
89
+ // ERR_OUT_OF_RANGE out of runShell instead of becoming a tool error string.
90
+ // Normalize it to the unsigned-integer contract (falling back to the
91
+ // default) so a sloppy call cannot crash the Bash tool.
92
+ const safeTimeout = typeof timeout === 'number' && Number.isFinite(timeout) && timeout >= 0
93
+ ? Math.floor(timeout)
94
+ : 30_000;
87
95
  return new Promise((resolve) => {
88
96
  const options = {
89
97
  cwd: workdir,
90
- timeout,
98
+ timeout: safeTimeout,
91
99
  maxBuffer: 1024 * 1024 * 8,
92
100
  windowsHide: true,
93
101
  env: { ...process.env },
@@ -102,7 +110,7 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
102
110
  options.shell = '/bin/sh';
103
111
  }
104
112
  exec(command, options, (err, stdout, stderr) => {
105
- resolve(formatExecResult(err, stdout, stderr, timeout, []));
113
+ resolve(formatExecResult(err, stdout, stderr, safeTimeout, []));
106
114
  });
107
115
  });
108
116
  }
package/dist/subagent.js CHANGED
@@ -23,9 +23,16 @@ export const SUBAGENT_REPORT_CAP = 20_000;
23
23
  export function capReport(text, cap = SUBAGENT_REPORT_CAP) {
24
24
  if (text.length <= cap)
25
25
  return text;
26
- return (text.slice(0, cap) +
26
+ // Never split a surrogate PAIR (an emoji / astral CJK char): the high half
27
+ // alone is a lone surrogate. The report becomes the parent chat's tool
28
+ // result, so the same rule as truncateToolResult() applies.
29
+ let cut = cap;
30
+ const c = text.charCodeAt(cut - 1);
31
+ if (c >= 0xd800 && c <= 0xdbff)
32
+ cut--;
33
+ return (text.slice(0, cut) +
27
34
  '\n… [report truncated: ' +
28
- (text.length - cap) +
35
+ (text.length - cut) +
29
36
  ' chars omitted]');
30
37
  }
31
38
  /**
@@ -171,8 +178,14 @@ export function createSubagentRunner(opts) {
171
178
  hookOut = await runLifecycleHooks(loadHooks(workdir), 'SubagentStop', workdir, { type: req.type, ok: failed ? 'false' : 'true' });
172
179
  }
173
180
  catch { }
174
- if (failed)
175
- return { ok: false, text: failed };
181
+ // A failed subagent must NOT lose the hook's stdout: a SubagentStop hook
182
+ // that reports the broken state is most useful exactly when the run died.
183
+ if (failed) {
184
+ return {
185
+ ok: false,
186
+ text: capReport(hookOut ? failed + '\n\n' + hookOut : failed),
187
+ };
188
+ }
176
189
  return {
177
190
  ok: true,
178
191
  text: capReport(hookOut ? report + '\n\n' + hookOut : report),
@@ -79,5 +79,18 @@ export function looksLikeToolError(result) {
79
79
  head.startsWith('missing required argument') ||
80
80
  head.startsWith('failed') ||
81
81
  head.includes('command failed') ||
82
- head.includes('exit code'));
82
+ head.includes('exit code') ||
83
+ // NOT every failure is a THROWN error: a PreToolUse hook, the permission
84
+ // policy and the operator's denial BLOCK the call and return their message
85
+ // as the result; an unknown tool and an aborted/timed-out run are reported
86
+ // in words too. Those calls never did their work, so a green check next to
87
+ // them lied to the operator.
88
+ head.startsWith('blocked by') ||
89
+ head.startsWith('denied by') ||
90
+ head.startsWith('unknown tool') ||
91
+ head.startsWith('subagents are not available') ||
92
+ head.startsWith('subagent failed') ||
93
+ head.startsWith('subagent error') ||
94
+ head.includes('process killed') ||
95
+ head.startsWith('(aborted by the operator)'));
83
96
  }
@@ -12,6 +12,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // callbacks (the SAME ones the legacy menu takes), so the logic is shared.
13
13
  import { useMemo, useState } from 'react';
14
14
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
15
+ import { translate } from '../i18n.js';
15
16
  import { Checkbox, Select, UI, HintBar } from './widgets.js';
16
17
  /** Strip ANSI to measure a line's visible width. */
17
18
  function stripAnsi(s) {
@@ -37,8 +38,11 @@ function displayValue(field, value, t) {
37
38
  return String(value);
38
39
  }
39
40
  export function ConfigMenuApp(opts) {
40
- const { fields, t, get, set, reset, getDefault } = opts;
41
+ const { fields, t, get, set, reset, getDefault, locale = 'en' } = opts;
41
42
  const { exit } = useApp();
43
+ // The hint bar is operator text: it follows the SAME locale the labels use
44
+ // (a hardcoded Russian bar showed up in an English UI).
45
+ const hint = translate(locale);
42
46
  const { columns, rows } = useWindowSize();
43
47
  const [cursor, setCursor] = useState(0);
44
48
  const [filterQuery, setFilterQuery] = useState('');
@@ -327,11 +331,11 @@ export function ConfigMenuApp(opts) {
327
331
  items.push(_jsx(Text, { color: UI.dim, children: ' ' + t('cfg.menu.no_match') }, "none"));
328
332
  }
329
333
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: UI.title, children: t('cfg.menu.title') }), _jsx(Text, { color: UI.dim, children: (visible.length ? cursor + 1 : 0) + '/' + visible.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), items, _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(Math.max(10, (columns || 80) - 6)) }), editing ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.edit_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + editBuf })] })) : filterMode ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: UI.text, children: t('cfg.menu.filter_hint') }), _jsx(Text, { color: UI.accent, children: '❯ ' + filterQuery })] })) : message ? (_jsx(Text, { color: UI.ok, children: message })) : filterQuery ? (_jsx(Text, { color: UI.dim, children: t('cfg.menu.filter_active', { q: filterQuery }) })) : null, _jsx(HintBar, { hints: [
330
- ['↑/↓', 'выбор'],
331
- ['←/→', 'изменить'],
332
- ['Enter', 'правка'],
333
- ['/', 'поиск'],
334
- ['d', 'сброс'],
335
- ['q', 'выход'],
334
+ ['↑/↓', hint('tui.hint.select')],
335
+ ['←/→', hint('tui.hint.change')],
336
+ ['Enter', hint('tui.hint.edit')],
337
+ ['/', hint('tui.hint.search')],
338
+ ['d', hint('tui.hint.reset')],
339
+ ['q', hint('tui.hint.quit')],
336
340
  ] })] }));
337
341
  }
@@ -5,14 +5,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  import { useState } from 'react';
6
6
  import { Box, Text, useInput, useApp } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
+ import { translate } from '../i18n.js';
8
9
  function Button({ label, focused, danger, }) {
9
10
  const color = danger ? UI.err : UI.on;
10
11
  return (_jsxs(Text, { children: [focused ? _jsx(Text, { color: color, children: '▸ ' }) : _jsx(Text, { children: ' ' }), _jsx(Text, { color: focused ? color : UI.dim, bold: focused, children: label })] }));
11
12
  }
12
- export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, }) {
13
+ export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, locale = 'en', }) {
13
14
  // Focus starts on "no" — a stray Enter must not confirm a destructive action.
14
15
  const [yes, setYes] = useState(false);
15
16
  const { exit } = useApp();
17
+ const t = translate(locale);
16
18
  const answer = (ok) => {
17
19
  onAnswer(ok);
18
20
  exit();
@@ -35,8 +37,8 @@ export function ConfirmDialog({ title, message, yesLabel, noLabel, onAnswer, })
35
37
  }
36
38
  });
37
39
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.warn, paddingX: 2, paddingY: 0, children: [_jsx(Text, { color: UI.warn, bold: true, children: '⚠ ' + title }), message ? _jsx(Text, { color: UI.text, children: message }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Button, { label: yesLabel, focused: yes, danger: true }), _jsx(Text, { children: ' ' }), _jsx(Button, { label: noLabel, focused: !yes })] }), _jsx(HintBar, { hints: [
38
- ['←/→', 'выбор'],
39
- ['Enter', 'ok'],
40
+ ['←/→', t('tui.hint.select')],
41
+ ['Enter', t('tui.hint.ok')],
40
42
  ['y/n', ''],
41
43
  ] })] }));
42
44
  }
@@ -5,9 +5,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  import { useMemo, useState } from 'react';
6
6
  import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
- export function SelectList({ title, items, initial, onSelect, }) {
8
+ import { translate } from '../i18n.js';
9
+ export function SelectList({ title, items, initial, locale = 'en', onSelect, }) {
9
10
  const { exit } = useApp();
10
11
  const { rows, columns } = useWindowSize();
12
+ const t = translate(locale);
11
13
  const start = Math.max(0, items.findIndex((it) => it.value === initial));
12
14
  const [cursor, setCursor] = useState(start);
13
15
  const maxRows = Math.max(3, (rows || 24) - 7);
@@ -47,8 +49,8 @@ export function SelectList({ title, items, initial, onSelect, }) {
47
49
  const sel = idx === cursor;
48
50
  return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: it.label }), it.detail ? _jsx(Text, { color: UI.dim, children: ' ' + it.detail }) : null] }, it.value));
49
51
  }), _jsx(HintBar, { hints: [
50
- ['↑/↓', 'выбор'],
51
- ['Enter', 'ok'],
52
- ['Esc', 'отмена'],
52
+ ['↑/↓', t('tui.hint.select')],
53
+ ['Enter', t('tui.hint.ok')],
54
+ ['Esc', t('tui.hint.cancel')],
53
55
  ] })] }));
54
56
  }
@@ -48,7 +48,16 @@ export function parseWheel(chunk) {
48
48
  let m;
49
49
  while ((m = re.exec(chunk)) !== null) {
50
50
  const cb = Number(m[1]);
51
+ // SGR reports a RELEASE as the same button number with a lowercase `m`.
52
+ // Only press events are wheels: a terminal that sends 1000/1002/1003
53
+ // release reports (xterm does for button events) would otherwise count
54
+ // every notch TWICE — once for the press and once for its release.
55
+ if (m[0].endsWith('m'))
56
+ continue;
51
57
  // Button code 64 = wheel up (scroll back), 65 = wheel down (scroll fwd).
58
+ // Modifier bits (4=shift, 8=alt, 16=ctrl) must be masked OFF first: the
59
+ // mask below used to keep bit 0, so Ctrl/Alt-wheel (Cb 80/72/... — odd or
60
+ // even) fell through and that notch was lost.
52
61
  const button = cb & 0b11000011;
53
62
  if (button === 64)
54
63
  delta += 1;
package/dist/web.js CHANGED
@@ -5,7 +5,37 @@ const DEFAULT_TIMEOUT = 20_000;
5
5
  const MAX_TEXT = 12_000;
6
6
  // Removes scripts, styles, and turns HTML into readable text. Exported for
7
7
  // tests — the entity/whitespace/collapse branches are pure and worth pinning.
8
+ // Decode ONE numeric HTML entity to its character. A hostile/broken page can
9
+ // carry `&#999999999;` (beyond Unicode), a surrogate (`&#xD800;`) or a huge
10
+ // hex value; String.fromCodePoint THROWS RangeError on those, and the throw
11
+ // escaped htmlToText into the WebFetch handler — the whole page came back as
12
+ // "Error fetching <url>: Invalid code point 999999999". An undecodable
13
+ // reference is kept VERBATIM instead.
14
+ //
15
+ // A surrogate PAIR written as two references (`&#55357;&#56832;`) is ONE
16
+ // astral character (an emoji): decoding each half alone left two lone
17
+ // surrogates in the text. A lone high surrogate is remembered and JOINED with
18
+ // the low surrogate that immediately follows it (the replacer runs in order).
19
+ let pendingHighSurrogate = null;
20
+ function decodeEntityCode(cp) {
21
+ if (!Number.isInteger(cp) || cp < 0 || cp > 0x10ffff)
22
+ return '';
23
+ if (cp >= 0xd800 && cp <= 0xdbff) {
24
+ pendingHighSurrogate = String.fromCharCode(cp);
25
+ return '';
26
+ }
27
+ if (cp >= 0xdc00 && cp <= 0xdfff) {
28
+ const high = pendingHighSurrogate;
29
+ pendingHighSurrogate = null;
30
+ return high ? high + String.fromCharCode(cp) : String.fromCharCode(cp);
31
+ }
32
+ pendingHighSurrogate = null;
33
+ return String.fromCodePoint(cp);
34
+ }
8
35
  export function htmlToText(html) {
36
+ // Reset the cross-reference surrogate state: it must never leak from one
37
+ // document to the next.
38
+ pendingHighSurrogate = null;
9
39
  // Remove blocks we don't need
10
40
  let s = html
11
41
  .replace(/<script[\s\S]*?<\/script>/gi, '')
@@ -35,8 +65,8 @@ export function htmlToText(html) {
35
65
  .replace(/&mdash;/g, '—')
36
66
  .replace(/&ndash;/g, '–')
37
67
  .replace(/&hellip;/g, '…')
38
- .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
39
- .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)));
68
+ .replace(/&#(\d+);/g, (_, n) => decodeEntityCode(Number(n)))
69
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => decodeEntityCode(parseInt(n, 16)));
40
70
  // Collapse blank lines
41
71
  s = s
42
72
  .split('\n')
@@ -52,6 +82,49 @@ export function htmlToText(html) {
52
82
  // or localhost services. We resolve the hostname and refuse private / loopback
53
83
  // / link-local addresses. Best-effort: a DNS rebind between the check and the
54
84
  // fetch is out of scope for a CLI tool, but the common cases are covered.
85
+ // Parse an inet_aton-style IPv4 literal (the form WHATWG URL and the OS also
86
+ // accept) into a uint32, or null when it is not one. Rules: 1-4 parts; each
87
+ // part may be hex (0x…), octal (leading 0) or decimal; the LAST part fills all
88
+ // remaining bytes, so `127.1` == 127.0.0.1 and `2130706433` == 127.0.0.1.
89
+ export function parseLegacyIpv4(host) {
90
+ const h = String(host || '').trim();
91
+ if (!h)
92
+ return null;
93
+ const parts = h.split('.');
94
+ if (parts.length < 1 || parts.length > 4)
95
+ return null;
96
+ const nums = [];
97
+ for (const p of parts) {
98
+ if (p === '')
99
+ return null;
100
+ let v;
101
+ if (/^0[xX][0-9a-fA-F]+$/.test(p))
102
+ v = parseInt(p.slice(2), 16);
103
+ else if (/^0[0-7]+$/.test(p))
104
+ v = parseInt(p.slice(1), 8);
105
+ else if (/^\d+$/.test(p))
106
+ v = parseInt(p, 10);
107
+ else
108
+ return null;
109
+ if (!Number.isFinite(v) || v < 0)
110
+ return null;
111
+ nums.push(v);
112
+ }
113
+ // Every part except the last must fit one byte; the last fills the rest.
114
+ for (let i = 0; i < nums.length - 1; i++) {
115
+ if (nums[i] > 0xff)
116
+ return null;
117
+ }
118
+ const lastMax = Math.pow(256, 5 - nums.length) - 1;
119
+ if (nums[nums.length - 1] > lastMax)
120
+ return null;
121
+ let n = 0;
122
+ for (let i = 0; i < nums.length - 1; i++) {
123
+ n += nums[i] * Math.pow(256, 3 - i);
124
+ }
125
+ n += nums[nums.length - 1];
126
+ return n >>> 0;
127
+ }
55
128
  export function isPrivateIp(ip) {
56
129
  const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
57
130
  if (v4) {
@@ -68,6 +141,22 @@ export function isPrivateIp(ip) {
68
141
  return true; // CGNAT
69
142
  return false;
70
143
  }
144
+ // A NON-canonical IPv4 literal is still a number: WHATWG URL and inet_aton
145
+ // accept 1-4 parts with decimal/octal/hex digits (`2130706433`,
146
+ // `0x7f.1`, `0177.0.0.1`, `127.1` all mean 127.0.0.1). Recognising only the
147
+ // dotted-quad form let those through the SSRF guard (a classic bypass).
148
+ if (/^[0-9a-fx.]+$/i.test(ip) && !ip.includes(':')) {
149
+ const n = parseLegacyIpv4(ip);
150
+ if (n !== null) {
151
+ return isPrivateIp(((n >>> 24) & 0xff) +
152
+ '.' +
153
+ ((n >>> 16) & 0xff) +
154
+ '.' +
155
+ ((n >>> 8) & 0xff) +
156
+ '.' +
157
+ (n & 0xff));
158
+ }
159
+ }
71
160
  const low = ip.toLowerCase();
72
161
  if (low === '::1' || low === '::')
73
162
  return true;
@@ -123,7 +212,10 @@ export async function assertPublicUrl(rawUrl) {
123
212
  if (u.protocol !== 'http:' && u.protocol !== 'https:') {
124
213
  throw new Error(`unsupported protocol: ${u.protocol}`);
125
214
  }
126
- const host = u.hostname;
215
+ // The URL API keeps the BRACKETS of an IPv6 literal (`[::ffff:7f00:1]`),
216
+ // and net.isIP() rejects a bracketed host — so the address fell through to
217
+ // the DNS lookup branch and the literal was never checked as an address.
218
+ const host = u.hostname.replace(/^\[(.*)\]$/, '$1');
127
219
  if (isPrivateHostname(host)) {
128
220
  throw new Error(`blocked private host: ${host}`);
129
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.76.0",
3
+ "version": "2.77.0",
4
4
  "description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
5
5
  "type": "module",
6
6
  "bin": {