zames_pro 2.75.6 → 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 +46 -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 +104 -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 +54 -0
- package/dist/i18n/self-review.js +2 -2
- package/dist/index.js +33 -5
- 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/TuiEditor.js +4 -0
- package/dist/tui/edit.js +13 -22
- package/dist/tui/keys.js +12 -12
- package/dist/tui/useMouseWheel.js +9 -0
- package/dist/web.js +95 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -530,9 +530,18 @@ async function refreshDynamicCommands(workdir) {
|
|
|
530
530
|
for (const s of skills) {
|
|
531
531
|
if (!s.userInvokable)
|
|
532
532
|
continue;
|
|
533
|
+
// A well-known skill (built-in or bundled methodology) has a localized
|
|
534
|
+
// description keyed by NAME — its SKILL.md frontmatter is agent-facing
|
|
535
|
+
// English, which must not leak into a Russian UI. `t` returns the key
|
|
536
|
+
// itself when it is missing, so an unknown (user-authored) skill falls
|
|
537
|
+
// back to its own description.
|
|
538
|
+
const descKey = 'skill.desc.' + s.name;
|
|
539
|
+
const localized = t(descKey);
|
|
533
540
|
out.push({
|
|
534
541
|
name: '/' + s.name,
|
|
535
|
-
description:
|
|
542
|
+
description: localized !== descKey
|
|
543
|
+
? localized
|
|
544
|
+
: s.description || t('help.cmd.skill'),
|
|
536
545
|
});
|
|
537
546
|
}
|
|
538
547
|
const cmds = await loadCommands(workdir);
|
|
@@ -1099,7 +1108,7 @@ async function main() {
|
|
|
1099
1108
|
if (editor)
|
|
1100
1109
|
editor.pause();
|
|
1101
1110
|
try {
|
|
1102
|
-
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);
|
|
1103
1112
|
}
|
|
1104
1113
|
finally {
|
|
1105
1114
|
if (editor)
|
|
@@ -1901,6 +1910,7 @@ async function main() {
|
|
|
1901
1910
|
getDefault: (path) => getByPath(DEFAULTS, path),
|
|
1902
1911
|
set: (field, raw) => configSetRaw(field, raw),
|
|
1903
1912
|
reset: (field) => configResetField(field),
|
|
1913
|
+
locale: currentLocale,
|
|
1904
1914
|
};
|
|
1905
1915
|
try {
|
|
1906
1916
|
// Prefer the Ink menu (nicer, resize-aware) when the TUI is enabled;
|
|
@@ -1981,6 +1991,7 @@ async function main() {
|
|
|
1981
1991
|
const { selectListInk } = await import('./tui/run-select.js');
|
|
1982
1992
|
picked = await selectListInk({
|
|
1983
1993
|
title: t('chats.pick_title'),
|
|
1994
|
+
locale: currentLocale,
|
|
1984
1995
|
items: chats.map((c, i) => ({
|
|
1985
1996
|
value: c.id,
|
|
1986
1997
|
label: String(i + 1).padStart(2, ' ') + '. ' + c.title,
|
|
@@ -2014,6 +2025,7 @@ async function main() {
|
|
|
2014
2025
|
const { selectListInk } = await import('./tui/run-select.js');
|
|
2015
2026
|
picked = await selectListInk({
|
|
2016
2027
|
title: t('sessions.pick_title'),
|
|
2028
|
+
locale: currentLocale,
|
|
2017
2029
|
items: sessions.map((s, i) => ({
|
|
2018
2030
|
value: s.id,
|
|
2019
2031
|
label: String(i + 1).padStart(2, ' ') +
|
|
@@ -2582,8 +2594,9 @@ async function main() {
|
|
|
2582
2594
|
items: models.map((m) => ({
|
|
2583
2595
|
value: m.id,
|
|
2584
2596
|
label: m.name || m.id,
|
|
2585
|
-
detail: m.id === config.qwen.model ? '
|
|
2597
|
+
detail: m.id === config.qwen.model ? t('tui.list.current') : m.id,
|
|
2586
2598
|
})),
|
|
2599
|
+
locale: currentLocale,
|
|
2587
2600
|
});
|
|
2588
2601
|
}
|
|
2589
2602
|
catch {
|
|
@@ -2775,6 +2788,13 @@ async function main() {
|
|
|
2775
2788
|
const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
|
|
2776
2789
|
const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
|
|
2777
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
|
+
}
|
|
2778
2798
|
if (!ms || !taskStr) {
|
|
2779
2799
|
console.error(theme.error(t('sched.loop_usage')));
|
|
2780
2800
|
continue;
|
|
@@ -2795,6 +2815,12 @@ async function main() {
|
|
|
2795
2815
|
when: formatInterval(ms),
|
|
2796
2816
|
task: taskStr,
|
|
2797
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')));
|
|
2798
2824
|
continue;
|
|
2799
2825
|
}
|
|
2800
2826
|
if (lower === '/cron' || lower.startsWith('/cron ')) {
|
|
@@ -2824,6 +2850,7 @@ async function main() {
|
|
|
2824
2850
|
when: cronExpr + ' (in ~' + nextIn + ')',
|
|
2825
2851
|
task: taskStr,
|
|
2826
2852
|
})));
|
|
2853
|
+
console.log(theme.dim(t('sched.added_hint')));
|
|
2827
2854
|
continue;
|
|
2828
2855
|
}
|
|
2829
2856
|
if (lower === '/jobs' || lower.startsWith('/jobs ')) {
|
|
@@ -3289,14 +3316,15 @@ async function main() {
|
|
|
3289
3316
|
console.log(theme.system(mod.commands.formatDiff(out, { maxLines: 400 }, t)));
|
|
3290
3317
|
continue;
|
|
3291
3318
|
}
|
|
3292
|
-
if (lower === '/diffstat') {
|
|
3319
|
+
if (lower === '/diffstat' || lower.startsWith('/diffstat ')) {
|
|
3320
|
+
const staged = lower.indexOf('--staged') !== -1;
|
|
3293
3321
|
const { runGitArgs } = await import('./gitTools.js');
|
|
3294
3322
|
const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], currentWorkdir, 5000);
|
|
3295
3323
|
if (probe.trim() !== 'true') {
|
|
3296
3324
|
console.error(theme.error(t('diff.not_repo')));
|
|
3297
3325
|
continue;
|
|
3298
3326
|
}
|
|
3299
|
-
const out = await runGitArgs(
|
|
3327
|
+
const out = await runGitArgs(mod.commands.diffStatGitArgs(staged), currentWorkdir, 20_000);
|
|
3300
3328
|
console.log(theme.system(mod.commands.formatDiffStat(out, { maxLines: 60 }, t)));
|
|
3301
3329
|
continue;
|
|
3302
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
|
}
|
package/dist/tui/TuiEditor.js
CHANGED
|
@@ -254,6 +254,10 @@ export class TuiEditor {
|
|
|
254
254
|
this.printAbove(theme.userBar('▌ ') +
|
|
255
255
|
theme.user(wrapToWidth(display, contentWidth() - 2)));
|
|
256
256
|
this.store.set({ pastes: [] });
|
|
257
|
+
// Sending a message pins the log back to the newest content: the operator
|
|
258
|
+
// expects their message (and the answer) at the BOTTOM, so a viewport left
|
|
259
|
+
// scrolled-up (PgUp / wheel) must snap to scroll=0 on submit.
|
|
260
|
+
this.store.set({ scroll: 0 });
|
|
257
261
|
this.onSubmit?.(text, atts);
|
|
258
262
|
if (this.busy)
|
|
259
263
|
this.thinking();
|
package/dist/tui/edit.js
CHANGED
|
@@ -82,35 +82,26 @@ export function deleteWordLeft(s) {
|
|
|
82
82
|
return { ...s, buf: chars.join(''), cursor: i };
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
|
-
* Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete)
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* cursor". Deleting the word on the LEFT there matches what other terminal
|
|
91
|
-
* editors do and what the operator expects, while keeping the standard
|
|
92
|
-
* right-delete when the cursor is mid-line.
|
|
85
|
+
* Delete the word on the RIGHT of the cursor (Ctrl+Delete / Alt+Delete):
|
|
86
|
+
* skip the whitespace after the cursor, then delete up to the end of the next
|
|
87
|
+
* word — the mirror of deleteWordLeft(). At the end of the line there is
|
|
88
|
+
* nothing to the right, so the buffer is left UNCHANGED (never a surprise
|
|
89
|
+
* leftward delete), matching the legacy LineEditor's _deleteWordRight().
|
|
93
90
|
*/
|
|
94
91
|
export function deleteWordRight(s) {
|
|
95
92
|
const chars = charsOf(s);
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
while (probe < chars.length &&
|
|
100
|
-
chars[probe] !== NL &&
|
|
101
|
-
/\s/.test(chars[probe])) {
|
|
102
|
-
probe++;
|
|
103
|
-
}
|
|
104
|
-
if (probe >= chars.length || chars[probe] === NL) {
|
|
105
|
-
return deleteWordLeft(s);
|
|
106
|
-
}
|
|
93
|
+
// Stop at a NEWLINE: it is whitespace for /\s/ but must never be swallowed
|
|
94
|
+
// by a word delete (the old code guarded this; without the guard the key
|
|
95
|
+
// ate the line break).
|
|
107
96
|
let i = s.cursor;
|
|
108
|
-
while (i < chars.length && /\s/.test(chars[i]))
|
|
97
|
+
while (i < chars.length && chars[i] !== NL && /\s/.test(chars[i]))
|
|
109
98
|
i++;
|
|
110
|
-
while (i < chars.length && !/\s/.test(chars[i]))
|
|
99
|
+
while (i < chars.length && chars[i] !== NL && !/\s/.test(chars[i]))
|
|
111
100
|
i++;
|
|
101
|
+
if (i === s.cursor)
|
|
102
|
+
return s;
|
|
112
103
|
chars.splice(s.cursor, i - s.cursor);
|
|
113
|
-
return { ...s, buf: chars.join('') };
|
|
104
|
+
return { ...s, buf: chars.join(''), cursor: s.cursor };
|
|
114
105
|
}
|
|
115
106
|
/** Delete from the cursor to the end of the current LOGICAL line (Ctrl+K). */
|
|
116
107
|
export function killToLineEnd(s) {
|
package/dist/tui/keys.js
CHANGED
|
@@ -35,8 +35,8 @@ export function parseCsiU(raw) {
|
|
|
35
35
|
if (cp === 13 && (ctrl || alt))
|
|
36
36
|
return { type: 'newline' };
|
|
37
37
|
if (cp === 3) {
|
|
38
|
-
// Ctrl/Alt+Delete deletes the word on the
|
|
39
|
-
return ctrl || alt ? { type: '
|
|
38
|
+
// Ctrl/Alt+Delete deletes the word on the RIGHT (standard editor binding).
|
|
39
|
+
return ctrl || alt ? { type: 'deleteWordRight' } : { type: 'delete' };
|
|
40
40
|
}
|
|
41
41
|
if (cp === 127) {
|
|
42
42
|
return ctrl || alt ? { type: 'deleteWordLeft' } : { type: 'backspace' };
|
|
@@ -85,11 +85,11 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
|
|
|
85
85
|
return { type: 'wordLeft' };
|
|
86
86
|
if (key.rightArrow)
|
|
87
87
|
return { type: 'wordRight' };
|
|
88
|
-
// Ctrl+Delete deletes the word
|
|
89
|
-
//
|
|
90
|
-
//
|
|
88
|
+
// Ctrl+Delete deletes the word AFTER the cursor (the standard editor
|
|
89
|
+
// binding, same as the legacy LineEditor). Ctrl+Backspace deletes the word
|
|
90
|
+
// BEFORE it. Plain Delete still deletes one char to the right.
|
|
91
91
|
if (key.delete)
|
|
92
|
-
return { type: '
|
|
92
|
+
return { type: 'deleteWordRight' };
|
|
93
93
|
if (key.backspace)
|
|
94
94
|
return { type: 'deleteWordLeft' };
|
|
95
95
|
if (key.home)
|
|
@@ -143,16 +143,16 @@ export function dispatchKey(input, key, ctx, isVisualEdge) {
|
|
|
143
143
|
if (input === 'f')
|
|
144
144
|
return { type: 'wordRight' };
|
|
145
145
|
// VSCode sends Ctrl+Delete as `ESC d` (which Ink reports as Alt+d).
|
|
146
|
-
//
|
|
146
|
+
// Standard editor binding: delete the word AFTER the cursor.
|
|
147
147
|
if (input === 'd')
|
|
148
|
-
return { type: '
|
|
148
|
+
return { type: 'deleteWordRight' };
|
|
149
149
|
if (key.backspace)
|
|
150
150
|
return { type: 'deleteWordLeft' };
|
|
151
|
-
// Alt/Ctrl+Delete also deletes the word
|
|
152
|
-
//
|
|
153
|
-
//
|
|
151
|
+
// Alt/Ctrl+Delete also deletes the word AFTER the cursor (consistent with
|
|
152
|
+
// ESC d): some terminals encode Ctrl+Delete as meta+Delete rather than
|
|
153
|
+
// ESC d.
|
|
154
154
|
if (key.delete)
|
|
155
|
-
return { type: '
|
|
155
|
+
return { type: 'deleteWordRight' };
|
|
156
156
|
return null;
|
|
157
157
|
}
|
|
158
158
|
// ---- LF as a bare newline (Ctrl+J) ----
|