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 +40 -0
- package/README.md +9 -6
- package/dist/answer-parse.js +19 -3
- package/dist/changelog.js +6 -0
- package/dist/commands.js +50 -5
- package/dist/config.js +7 -0
- package/dist/context.js +5 -1
- package/dist/diagnostics.js +8 -1
- package/dist/goal.js +13 -4
- package/dist/i18n/deepseek.js +13 -0
- package/dist/i18n/help.js +8 -0
- package/dist/i18n/self-review.js +2 -2
- package/dist/index.js +23 -4
- package/dist/input.js +7 -1
- package/dist/mcp.js +9 -1
- package/dist/net-capture.js +37 -0
- package/dist/qwen-browser.js +6 -3
- package/dist/qwen-chats.js +9 -2
- package/dist/run-task.js +8 -2
- package/dist/scheduler.js +28 -3
- package/dist/shell.js +10 -2
- package/dist/subagent.js +17 -4
- package/dist/tool-summary.js +14 -1
- package/dist/tui/ConfigMenu.js +11 -7
- package/dist/tui/ConfirmDialog.js +5 -3
- package/dist/tui/SelectList.js +6 -4
- package/dist/tui/useMouseWheel.js +9 -0
- package/dist/web.js +95 -3
- package/package.json +1 -1
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 <
|
|
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 <
|
|
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»
|
package/dist/answer-parse.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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[
|
|
80
|
+
const raw = m[1].toUpperCase();
|
|
77
81
|
const status = raw === 'MET' ? 'met' : raw === 'IMPOSSIBLE' ? 'impossible' : 'not_met';
|
|
78
|
-
const rm = /REASON
|
|
79
|
-
const reason = rm
|
|
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
|
}
|
package/dist/i18n/deepseek.js
CHANGED
|
@@ -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': {
|
package/dist/i18n/self-review.js
CHANGED
|
@@ -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>
|
|
175
|
-
en: 'Restore: /resume-id <id> (full id) or
|
|
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 ? '
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/net-capture.js
CHANGED
|
@@ -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
|
}
|
package/dist/qwen-browser.js
CHANGED
|
@@ -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
|
package/dist/qwen-chats.js
CHANGED
|
@@ -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
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
175
|
-
|
|
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),
|
package/dist/tool-summary.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/ConfigMenu.js
CHANGED
|
@@ -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
|
}
|
package/dist/tui/SelectList.js
CHANGED
|
@@ -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
|
-
|
|
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 `�` (beyond Unicode), a surrogate (`�`) 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 (`��`) 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(/—/g, '—')
|
|
36
66
|
.replace(/–/g, '–')
|
|
37
67
|
.replace(/…/g, '…')
|
|
38
|
-
.replace(/&#(\d+);/g, (_, n) =>
|
|
39
|
-
.replace(/&#x([0-9a-f]+);/gi, (_, n) =>
|
|
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
|
-
|
|
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.
|
|
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": {
|