zames_pro 2.70.0 → 2.72.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 +32 -0
- package/README.md +8 -0
- package/dist/agent-loop.js +258 -857
- package/dist/answer-parse.js +765 -0
- package/dist/browser-chats.js +161 -0
- package/dist/browser-pure.js +238 -0
- package/dist/browser.js +45 -390
- package/dist/changelog.js +29 -0
- package/dist/cli-args.js +48 -0
- package/dist/compact.js +9 -0
- package/dist/config.js +24 -0
- package/dist/diagnostics.js +71 -0
- package/dist/hooks.js +30 -2
- package/dist/i18n/common.js +4 -0
- package/dist/i18n/config.js +8 -0
- package/dist/index.js +81 -24
- package/dist/input.js +25 -1
- package/dist/run-task.js +33 -1
- package/dist/shell.js +102 -1
- package/dist/statusline.js +52 -0
- package/dist/subagent.js +156 -0
- package/dist/system-prompt.js +38 -1
- package/dist/tools.js +105 -9
- package/package.json +10 -8
- package/scripts/changelog-draft.mts +38 -1
- package/scripts/{eslint-ts6.mjs → eslint-ts6.mts} +11 -4
- package/scripts/release.mts +36 -7
- package/scripts/{render-demo.mjs → render-demo.mts} +12 -11
- package/scripts/{self-smoke.mjs → self-smoke.mts} +86 -5
- /package/scripts/{clean-dist.mjs → clean-dist.mts} +0 -0
- /package/scripts/{coverage-gate.mjs → coverage-gate.mts} +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
export const DEFAULT_DIAGNOSTICS = {
|
|
3
|
+
enabled: false,
|
|
4
|
+
command: 'npx tsc --noEmit',
|
|
5
|
+
timeoutMs: 60_000,
|
|
6
|
+
tools: ['Edit', 'Write', 'MultiEdit', 'ApplyPatch'],
|
|
7
|
+
maxChars: 4000,
|
|
8
|
+
};
|
|
9
|
+
/** True when the tool mutates files and so should trigger a check. */
|
|
10
|
+
export function shouldRunDiagnostics(cfg, tool) {
|
|
11
|
+
if (!cfg || !cfg.enabled)
|
|
12
|
+
return false;
|
|
13
|
+
if (!cfg.command || !cfg.command.trim())
|
|
14
|
+
return false;
|
|
15
|
+
return (cfg.tools || DEFAULT_DIAGNOSTICS.tools).includes(tool);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Collapse a check's output to the lines a model actually needs. A clean run
|
|
19
|
+
* (empty output, or tsc's "no errors") returns null: there is nothing to say,
|
|
20
|
+
* and a noise line on every edit would train the model to ignore it.
|
|
21
|
+
*/
|
|
22
|
+
export function summarizeDiagnostics(raw, maxChars) {
|
|
23
|
+
const text = String(raw || '').trim();
|
|
24
|
+
if (!text)
|
|
25
|
+
return null;
|
|
26
|
+
// tsc prints nothing on success; eslint prints nothing too. Guard against a
|
|
27
|
+
// benign banner that some tools emit.
|
|
28
|
+
if (/^(no errors?\.?|0 problems?.*)$/im.test(text) && text.length < 40) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Keep only error-looking lines when we can recognise them, so a long build
|
|
32
|
+
// log collapses to the actual problems. Fall back to the whole output.
|
|
33
|
+
const lines = text.split(/\r?\n/);
|
|
34
|
+
const errLines = lines.filter((l) => /error|warning|✖|✗|TS\d{4}|\bnot assignable\b|\bcannot find\b/i.test(l));
|
|
35
|
+
const chosen = errLines.length ? errLines : lines;
|
|
36
|
+
let out = chosen.join('\n');
|
|
37
|
+
if (out.length > maxChars) {
|
|
38
|
+
out = out.slice(0, maxChars) + '\n… [diagnostics truncated]';
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Run the diagnostics command and return a human-readable summary, or null
|
|
44
|
+
* when it is disabled / clean. Never throws: a failed spawn is treated as
|
|
45
|
+
* "no diagnostics" so an edit is never blocked by the checker.
|
|
46
|
+
*/
|
|
47
|
+
export function runDiagnostics(workdir, cfg) {
|
|
48
|
+
if (!cfg || !cfg.enabled || !cfg.command || !cfg.command.trim())
|
|
49
|
+
return null;
|
|
50
|
+
const isWin = process.platform === 'win32';
|
|
51
|
+
let res;
|
|
52
|
+
try {
|
|
53
|
+
res = spawnSync(cfg.command, {
|
|
54
|
+
cwd: workdir,
|
|
55
|
+
shell: isWin ? process.env.ComSpec || true : '/bin/sh',
|
|
56
|
+
timeout: cfg.timeoutMs || DEFAULT_DIAGNOSTICS.timeoutMs,
|
|
57
|
+
encoding: 'utf-8',
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (res.error)
|
|
66
|
+
return null;
|
|
67
|
+
const out = String(res.stdout || '') + String(res.stderr || '');
|
|
68
|
+
const summary = summarizeDiagnostics(out, cfg.maxChars || DEFAULT_DIAGNOSTICS.maxChars);
|
|
69
|
+
// Exit 0 with a summary means warnings only; still surface them.
|
|
70
|
+
return summary;
|
|
71
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { exec } from 'child_process';
|
|
4
|
+
const LIFECYCLE_EVENTS = [
|
|
5
|
+
'SessionStart',
|
|
6
|
+
'UserPromptSubmit',
|
|
7
|
+
'PreCompact',
|
|
8
|
+
'Stop',
|
|
9
|
+
'SubagentStop',
|
|
10
|
+
];
|
|
4
11
|
// A hook is a helper, not a task: if it has not finished in ten seconds the
|
|
5
12
|
// tool call must not be held hostage by it.
|
|
6
13
|
const HOOK_TIMEOUT_MS = 10_000;
|
|
@@ -33,6 +40,11 @@ export function loadHooks(workdir) {
|
|
|
33
40
|
return {
|
|
34
41
|
PreToolUse: normalizeEntries(obj['PreToolUse']),
|
|
35
42
|
PostToolUse: normalizeEntries(obj['PostToolUse']),
|
|
43
|
+
SessionStart: normalizeEntries(obj['SessionStart']),
|
|
44
|
+
UserPromptSubmit: normalizeEntries(obj['UserPromptSubmit']),
|
|
45
|
+
PreCompact: normalizeEntries(obj['PreCompact']),
|
|
46
|
+
Stop: normalizeEntries(obj['Stop']),
|
|
47
|
+
SubagentStop: normalizeEntries(obj['SubagentStop']),
|
|
36
48
|
};
|
|
37
49
|
}
|
|
38
50
|
function normalizeEntries(value) {
|
|
@@ -141,11 +153,27 @@ export async function runPreToolUse(hooks, tool, args, workdir) {
|
|
|
141
153
|
* its result must be reported.
|
|
142
154
|
*/
|
|
143
155
|
export async function runPostToolUse(hooks, tool, args, result, workdir) {
|
|
156
|
+
return runEventHooks(hooks, 'PostToolUse', workdir, tool, args, result);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Run every hook registered for a LIFECYCLE event (SessionStart, Stop, …).
|
|
160
|
+
* Best-effort like the tool hooks: the combined stdout is returned to the
|
|
161
|
+
* caller for a notice, and errors never break the task. `tool`/`args` are
|
|
162
|
+
* informational for these events (there is no tool call), so they default to
|
|
163
|
+
* the event name and an empty object.
|
|
164
|
+
*/
|
|
165
|
+
export async function runLifecycleHooks(hooks, event, workdir, extra = {}) {
|
|
166
|
+
if (!LIFECYCLE_EVENTS.includes(event))
|
|
167
|
+
return '';
|
|
168
|
+
return runEventHooks(hooks, event, workdir, event, extra);
|
|
169
|
+
}
|
|
170
|
+
async function runEventHooks(hooks, event, workdir, tool, args, result) {
|
|
171
|
+
const list = hooks?.[event];
|
|
144
172
|
const parts = [];
|
|
145
|
-
for (const entry of
|
|
173
|
+
for (const entry of list ?? []) {
|
|
146
174
|
if (!matchesHook(entry, tool))
|
|
147
175
|
continue;
|
|
148
|
-
const r = await runHook(entry,
|
|
176
|
+
const r = await runHook(entry, event, tool, args, workdir, result);
|
|
149
177
|
const out = r.stdout.trim();
|
|
150
178
|
if (out)
|
|
151
179
|
parts.push(out);
|
package/dist/i18n/common.js
CHANGED
|
@@ -116,6 +116,10 @@ export const commonMessages = {
|
|
|
116
116
|
ru: 'Разрешить выполнение?',
|
|
117
117
|
en: 'Allow this call?',
|
|
118
118
|
},
|
|
119
|
+
'perm.ask_sub': {
|
|
120
|
+
ru: '⚠ Субагент запрашивает подтверждение: {tool} ({reason})',
|
|
121
|
+
en: '⚠ Subagent approval required: {tool} ({reason})',
|
|
122
|
+
},
|
|
119
123
|
'perm.allowed': {
|
|
120
124
|
ru: '✔ Разрешено оператором.',
|
|
121
125
|
en: '✔ Allowed by operator.',
|
package/dist/i18n/config.js
CHANGED
|
@@ -119,6 +119,14 @@ export const configMessages = {
|
|
|
119
119
|
ru: 'Пересылать системный промпт при возобновлении чата',
|
|
120
120
|
en: 'Resend the system prompt on chat resume',
|
|
121
121
|
},
|
|
122
|
+
'cfg.f.browser_subagents': {
|
|
123
|
+
ru: 'Субагенты: инструмент Task (отдельный чат)',
|
|
124
|
+
en: 'Subagents: the Task tool (separate chat)',
|
|
125
|
+
},
|
|
126
|
+
'cfg.f.browser_maxSubagents': {
|
|
127
|
+
ru: 'Максимум субагентов на задачу',
|
|
128
|
+
en: 'Max subagents per task',
|
|
129
|
+
},
|
|
122
130
|
'cfg.f.browser_deepThinking': {
|
|
123
131
|
ru: 'Глубокое мышление (долго; размышления не выводятся)',
|
|
124
132
|
en: 'Deep thinking (slow; reasoning is hidden)',
|
package/dist/index.js
CHANGED
|
@@ -6,9 +6,10 @@ import { existsSync, statSync } from 'fs';
|
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { theme } from './theme.js';
|
|
8
8
|
import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
|
|
9
|
-
import { createTools, mergeTools } from './tools.js';
|
|
9
|
+
import { createTools, mergeTools, filterToolsForReadOnly } from './tools.js';
|
|
10
10
|
import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
|
|
11
11
|
import { runAgentLoop } from './agent-loop.js';
|
|
12
|
+
import { createSubagentRunner } from './subagent.js';
|
|
12
13
|
import { createSpinner } from './spinner.js';
|
|
13
14
|
import { LineEditor } from './input.js';
|
|
14
15
|
import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
|
|
@@ -21,10 +22,13 @@ import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
|
|
|
21
22
|
import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
|
|
22
23
|
import { performCompact } from './compact.js';
|
|
23
24
|
import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
|
|
25
|
+
import { createCliArgs } from './cli-args.js';
|
|
24
26
|
import { renderMarkdown, setAnswerWidth } from './markdown.js';
|
|
27
|
+
import { runStatusLineCommand } from './statusline.js';
|
|
25
28
|
import { parseBacklogNote } from './backlog.js';
|
|
29
|
+
import { askOperatorConfirm, } from './run-task.js';
|
|
26
30
|
import { closeWeb } from './web.js';
|
|
27
|
-
import { assertCommandInsideRoot, runShell } from './shell.js';
|
|
31
|
+
import { assertCommandInsideRoot, runShell, killAllBackground, } from './shell.js';
|
|
28
32
|
import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
|
|
29
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
34
|
const NL = String.fromCharCode(10);
|
|
@@ -47,28 +51,9 @@ function authMarkerExists() {
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
// ---------- CLI parsing ----------
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
54
|
-
}
|
|
55
|
-
function hasFlag(flag) {
|
|
56
|
-
return args.includes(flag);
|
|
57
|
-
}
|
|
58
|
-
function getPositional() {
|
|
59
|
-
const positional = [];
|
|
60
|
-
for (let i = 0; i < args.length; i++) {
|
|
61
|
-
const a = args[i];
|
|
62
|
-
if (['--dir', '--task', '--max-iter', '--chat', '--output-format'].includes(a)) {
|
|
63
|
-
i++;
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
if (a.startsWith('--'))
|
|
67
|
-
continue;
|
|
68
|
-
positional.push(a);
|
|
69
|
-
}
|
|
70
|
-
return positional;
|
|
71
|
-
}
|
|
54
|
+
// The pure helpers live in src/cli-args.ts (C3) and are bound to this process's
|
|
55
|
+
// argv ONCE here. Call sites keep using getArg/hasFlag/getPositional.
|
|
56
|
+
const { getArg, hasFlag, getPositional } = createCliArgs(process.argv.slice(2));
|
|
72
57
|
// /compact asks the OLD chat for a summary. If browser.ask() exhausts its own
|
|
73
58
|
// rate-limit budget, aborting the whole compaction used to force the operator
|
|
74
59
|
// to start over. We retry the summary call a few times instead (inside
|
|
@@ -1121,6 +1106,46 @@ async function main() {
|
|
|
1121
1106
|
// Write the checklist whenever the agent changes it, so a restart/resume
|
|
1122
1107
|
// brings the tasks back (they used to be in-memory only).
|
|
1123
1108
|
todoStore.onChange = () => persistTodos();
|
|
1109
|
+
// Subagents (BACKLOG N36): the `Task` tool delegates a self-contained
|
|
1110
|
+
// sub-task to a nested agent loop in its OWN chat (context isolation — in
|
|
1111
|
+
// zames "context" IS the chat), then restores this chat and returns only
|
|
1112
|
+
// the report. Sequential by design (one browser, one send slot). On by
|
|
1113
|
+
// default via browser.subagents (its own budget caps the extra sends).
|
|
1114
|
+
const makeSubagentRunner = () => config.browser.subagents
|
|
1115
|
+
? createSubagentRunner({
|
|
1116
|
+
browser,
|
|
1117
|
+
workdir: currentWorkdir,
|
|
1118
|
+
locale: currentLocale,
|
|
1119
|
+
runAgentLoop: mod.runAgentLoop,
|
|
1120
|
+
buildTools: (readOnly) => {
|
|
1121
|
+
const base = mod.createTools(currentWorkdir, {
|
|
1122
|
+
undo,
|
|
1123
|
+
todos: todoStore,
|
|
1124
|
+
readOnly: false,
|
|
1125
|
+
subagents: false,
|
|
1126
|
+
});
|
|
1127
|
+
return readOnly ? filterToolsForReadOnly(base) : base;
|
|
1128
|
+
},
|
|
1129
|
+
transcript,
|
|
1130
|
+
maxSubagents: config.browser.maxSubagents,
|
|
1131
|
+
askDeadlineMs: config.browser.askDeadlineMs,
|
|
1132
|
+
maxAfterToolRetries: config.browser.maxAfterToolRetries,
|
|
1133
|
+
// An `ask` permission rule must also gate the subagent: otherwise a
|
|
1134
|
+
// guarded tool (e.g. a dangerous Bash command) would run inside the
|
|
1135
|
+
// subagent WITHOUT the operator's confirmation.
|
|
1136
|
+
onAskPermission: async (info) => {
|
|
1137
|
+
if (editor)
|
|
1138
|
+
editor.pause();
|
|
1139
|
+
try {
|
|
1140
|
+
return await askOperatorConfirm(null, t('perm.ask_sub', { tool: info.tool, reason: info.reason }));
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
if (editor)
|
|
1144
|
+
editor.resume();
|
|
1145
|
+
}
|
|
1146
|
+
},
|
|
1147
|
+
})
|
|
1148
|
+
: null;
|
|
1124
1149
|
// A compact "tasks: 2/5" summary for the status line. Empty when the list
|
|
1125
1150
|
// is empty, so an idle status is not cluttered.
|
|
1126
1151
|
const todosSummary = () => {
|
|
@@ -1217,6 +1242,8 @@ async function main() {
|
|
|
1217
1242
|
let reviewMode = null;
|
|
1218
1243
|
process.on('SIGINT', async () => {
|
|
1219
1244
|
running = false;
|
|
1245
|
+
// Kill any background processes (N33) so no dev-server outlives the agent.
|
|
1246
|
+
killAllBackground();
|
|
1220
1247
|
await browser.close().catch(() => { });
|
|
1221
1248
|
// Close the lazy headless browser from web.js, otherwise it would stay
|
|
1222
1249
|
// as a separate process after the agent exits.
|
|
@@ -1269,6 +1296,8 @@ async function main() {
|
|
|
1269
1296
|
// the typed text is never overwritten by output. In non-TTY (pipe) —
|
|
1270
1297
|
// the old promptOnce.
|
|
1271
1298
|
let editor = null;
|
|
1299
|
+
// N37 statusLine hook timer (cleared on exit).
|
|
1300
|
+
let statusLineTimer = null;
|
|
1272
1301
|
let waiter = null;
|
|
1273
1302
|
const takeInput = () => {
|
|
1274
1303
|
if (pendingQueue.length)
|
|
@@ -1319,6 +1348,28 @@ async function main() {
|
|
|
1319
1348
|
// Toggle icons (🧠 deep thinking, 🌐 web search) before the context
|
|
1320
1349
|
// counter. The editor pulls the live state on every render.
|
|
1321
1350
|
ed.onToggleQuery = () => browser.getToggleStatesSync();
|
|
1351
|
+
// N37 statusLine hook: run the configured command on its own timer (never
|
|
1352
|
+
// per-render — a spawn would block typing) and show its first stdout line
|
|
1353
|
+
// in the status line. Off when ui.statusLineCommand is empty.
|
|
1354
|
+
if (config.ui.statusLineCommand) {
|
|
1355
|
+
const statusLineInfo = () => ({
|
|
1356
|
+
chatId: currentChatId,
|
|
1357
|
+
queueLength: pendingQueue.length,
|
|
1358
|
+
tokens: browser.getLastTokenUsage(),
|
|
1359
|
+
contextLimit: config.ui.contextLimit,
|
|
1360
|
+
tasks: todosSummary(),
|
|
1361
|
+
locale: currentLocale,
|
|
1362
|
+
});
|
|
1363
|
+
const tick = async () => {
|
|
1364
|
+
const line = await runStatusLineCommand(currentWorkdir, config.ui.statusLineCommand, statusLineInfo()).catch(() => '');
|
|
1365
|
+
editor?.setStatusLine(line);
|
|
1366
|
+
};
|
|
1367
|
+
void tick();
|
|
1368
|
+
const interval = Math.max(2000, config.ui.statusLineIntervalMs || 10_000);
|
|
1369
|
+
statusLineTimer = setInterval(() => void tick(), interval);
|
|
1370
|
+
if (typeof statusLineTimer.unref === 'function')
|
|
1371
|
+
statusLineTimer.unref();
|
|
1372
|
+
}
|
|
1322
1373
|
ed.onAttach = async (raw) => {
|
|
1323
1374
|
// Case 1: the paste is the image data itself (data URL / base64 blob).
|
|
1324
1375
|
const image = parseImagePaste(raw);
|
|
@@ -3395,6 +3446,9 @@ async function main() {
|
|
|
3395
3446
|
undo,
|
|
3396
3447
|
todos: todoStore,
|
|
3397
3448
|
readOnly: planMode,
|
|
3449
|
+
// `Task` is only offered when subagents are enabled AND we are not in
|
|
3450
|
+
// plan (read-only) mode — a `general` subagent could write otherwise.
|
|
3451
|
+
subagents: config.browser.subagents && !planMode,
|
|
3398
3452
|
});
|
|
3399
3453
|
const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
|
|
3400
3454
|
if (editor)
|
|
@@ -3452,6 +3506,7 @@ async function main() {
|
|
|
3452
3506
|
goal: sessionGoal,
|
|
3453
3507
|
todosQuery: () => todosSummary(),
|
|
3454
3508
|
selfImprovement: devMode,
|
|
3509
|
+
onSubagent: makeSubagentRunner(),
|
|
3455
3510
|
}, inputAttachments);
|
|
3456
3511
|
}
|
|
3457
3512
|
finally {
|
|
@@ -3469,6 +3524,8 @@ async function main() {
|
|
|
3469
3524
|
editor.dispose();
|
|
3470
3525
|
if (scheduleTimer)
|
|
3471
3526
|
clearInterval(scheduleTimer);
|
|
3527
|
+
if (statusLineTimer)
|
|
3528
|
+
clearInterval(statusLineTimer);
|
|
3472
3529
|
await browser.close().catch(() => { });
|
|
3473
3530
|
await mod.closeWeb().catch(() => { });
|
|
3474
3531
|
if (mcpPool)
|
package/dist/input.js
CHANGED
|
@@ -163,6 +163,11 @@ export class LineEditor {
|
|
|
163
163
|
// A callback the editor calls before every render to read the agent's
|
|
164
164
|
// current task-list summary ("tasks: 2/5"), empty when there is no list.
|
|
165
165
|
onTasksQuery;
|
|
166
|
+
// Extra status text produced by an external command (N37 statusLine hook).
|
|
167
|
+
// Set via setStatusLine(); shown before the queue/tasks badges. Kept as a
|
|
168
|
+
// plain field because the CALLER runs the command on its own timer (a
|
|
169
|
+
// per-render spawn would block typing).
|
|
170
|
+
statusLine;
|
|
166
171
|
// Whether the DeepSeek "Deep thinking" / "Smart search" toggles are ON.
|
|
167
172
|
// Shown as two colored icons before the context counter: a dim gray icon
|
|
168
173
|
// means off, a teal-green icon means on.
|
|
@@ -256,6 +261,7 @@ export class LineEditor {
|
|
|
256
261
|
this.onToggleQuery = null;
|
|
257
262
|
this.onContextQuery = null;
|
|
258
263
|
this.queueLength = 0;
|
|
264
|
+
this.statusLine = '';
|
|
259
265
|
this.onQueueQuery = null;
|
|
260
266
|
this.onTasksQuery = null;
|
|
261
267
|
}
|
|
@@ -339,6 +345,11 @@ export class LineEditor {
|
|
|
339
345
|
return '';
|
|
340
346
|
}
|
|
341
347
|
}
|
|
348
|
+
// The statusLine hook text (N37), shown before the queue/tasks badges.
|
|
349
|
+
// A trailing space separates it from whatever follows; empty hides it.
|
|
350
|
+
_statusLineBadge() {
|
|
351
|
+
return this.statusLine ? theme.system(this.statusLine) + ' ' : '';
|
|
352
|
+
}
|
|
342
353
|
// Icons for the DeepSeek toggles: 🧠 "Deep thinking" and 🌐 "Smart search",
|
|
343
354
|
// shown before the context counter. Only the ENABLED toggles are shown, so
|
|
344
355
|
// the state is unambiguous WITHOUT relying on the terminal coloring the
|
|
@@ -623,6 +634,15 @@ export class LineEditor {
|
|
|
623
634
|
}
|
|
624
635
|
// Update the context window size (from config.ui.contextLimit). The next
|
|
625
636
|
// render recomputes the percentage and its color with the new limit.
|
|
637
|
+
// Set the statusLine hook text (N37). The caller runs the command on its
|
|
638
|
+
// own timer; this only stores + repaints. An empty string hides the badge.
|
|
639
|
+
setStatusLine(text) {
|
|
640
|
+
const t = String(text || '').trim();
|
|
641
|
+
if (t === this.statusLine)
|
|
642
|
+
return;
|
|
643
|
+
this.statusLine = t;
|
|
644
|
+
this._render();
|
|
645
|
+
}
|
|
626
646
|
setContextLimit(limit) {
|
|
627
647
|
if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
|
|
628
648
|
this.contextLimit = limit;
|
|
@@ -721,7 +741,11 @@ export class LineEditor {
|
|
|
721
741
|
// but the icons are still meaningful.
|
|
722
742
|
const ctxRaw = this._contextText();
|
|
723
743
|
this._refreshToggles();
|
|
724
|
-
const ctxText = this.
|
|
744
|
+
const ctxText = this._statusLineBadge() +
|
|
745
|
+
this._queueBadge() +
|
|
746
|
+
this._tasksBadge() +
|
|
747
|
+
this._toggleIcons() +
|
|
748
|
+
ctxRaw;
|
|
725
749
|
let statusOut = '';
|
|
726
750
|
let top = 0;
|
|
727
751
|
// Never fill a row to the FULL terminal width: on terminals with
|
package/dist/run-task.js
CHANGED
|
@@ -237,7 +237,7 @@ export function watchInput({ onEscape, onChange, onQueue, } = {}) {
|
|
|
237
237
|
}
|
|
238
238
|
export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
|
|
239
239
|
const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
|
|
240
|
-
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, } = opts;
|
|
240
|
+
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
|
|
241
241
|
// In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
|
|
242
242
|
// Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
|
|
243
243
|
// mode (pipes) - a regular spinner + watchInput.
|
|
@@ -279,6 +279,8 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
279
279
|
// Set when the loop ends without a real answer (iteration limit / watchdog)
|
|
280
280
|
// so the one-shot mode can exit non-zero. See N5.
|
|
281
281
|
let failure = null;
|
|
282
|
+
// N31: the machine-readable reason for the last task's outcome.
|
|
283
|
+
let outcomeKind = 'ok';
|
|
282
284
|
let next = {
|
|
283
285
|
text: taskText,
|
|
284
286
|
attachments,
|
|
@@ -358,6 +360,10 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
358
360
|
contextLimit,
|
|
359
361
|
getTokenUsage,
|
|
360
362
|
selfImprovement,
|
|
363
|
+
onSubagent,
|
|
364
|
+
// N38: the check command appended to a tool result after an edit
|
|
365
|
+
// (off by default; config.diagnostics).
|
|
366
|
+
diagnostics: deps.config.diagnostics,
|
|
361
367
|
});
|
|
362
368
|
// The loop may end WITHOUT a model answer: an exhausted iteration limit
|
|
363
369
|
// or an ask() watchdog. Surface it instead of a silent stop.
|
|
@@ -367,6 +373,14 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
367
373
|
ui.warning(outcome);
|
|
368
374
|
transcript?.log('agent_no_answer', { outcome });
|
|
369
375
|
failure = outcome;
|
|
376
|
+
// N31: a machine-readable reason so `--output-format jsonl` consumers
|
|
377
|
+
// (CI) can tell a real error from an exhausted limit / watchdog.
|
|
378
|
+
outcomeKind = outcome.startsWith('Iteration limit reached')
|
|
379
|
+
? 'iteration_limit'
|
|
380
|
+
: 'watchdog';
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
outcomeKind = 'ok';
|
|
370
384
|
}
|
|
371
385
|
// T7: one summary line per task (duration + tool calls + tokens spent).
|
|
372
386
|
// Printed even on an abort, so the operator sees what happened.
|
|
@@ -432,6 +446,23 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
432
446
|
sendSystemPrompt: false,
|
|
433
447
|
};
|
|
434
448
|
}
|
|
449
|
+
// Stop hook (N32): the whole task (including drained queue messages) is
|
|
450
|
+
// done. A cleanup/notification hook runs here. Best-effort.
|
|
451
|
+
try {
|
|
452
|
+
const { runLifecycleHooks } = await import('./hooks.js');
|
|
453
|
+
const out = await runLifecycleHooks(undefined, 'Stop', workdir, {
|
|
454
|
+
failure: failure ?? '',
|
|
455
|
+
});
|
|
456
|
+
if (out)
|
|
457
|
+
ui.warning(out);
|
|
458
|
+
}
|
|
459
|
+
catch { }
|
|
460
|
+
// N31: a single terminal event carrying the reason, so a jsonl consumer
|
|
461
|
+
// does not have to guess from the exit code alone.
|
|
462
|
+
transcript?.log('task_result', {
|
|
463
|
+
ok: !failure,
|
|
464
|
+
outcome: failure ? outcomeKind : 'ok',
|
|
465
|
+
});
|
|
435
466
|
return failure ? { ok: false, error: failure } : { ok: true };
|
|
436
467
|
}
|
|
437
468
|
catch (e) {
|
|
@@ -440,6 +471,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
440
471
|
if (debug)
|
|
441
472
|
console.error(e.stack);
|
|
442
473
|
transcript?.log('agent_error', { error: e.message });
|
|
474
|
+
transcript?.log('task_result', { ok: false, outcome: 'error' });
|
|
443
475
|
return { ok: false, error: e.message };
|
|
444
476
|
}
|
|
445
477
|
finally {
|
package/dist/shell.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { exec, execFile } from 'child_process';
|
|
1
|
+
import { exec, execFile, spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
4
5
|
// Shared shell runner for the Bash tool and the operator's `!command` escape.
|
|
5
6
|
// Kept in its own module so both call sites use the SAME sandbox guard and the
|
|
6
7
|
// SAME abort wiring; a second, ad-hoc runner would drift from the real one.
|
|
@@ -87,6 +88,106 @@ export function runShell(workdir, command, timeout = 30_000, signal) {
|
|
|
87
88
|
});
|
|
88
89
|
});
|
|
89
90
|
}
|
|
91
|
+
const BG_PROCS = new Map();
|
|
92
|
+
const BG_MAX_BUFFER = 1024 * 1024;
|
|
93
|
+
function bgTrim(p) {
|
|
94
|
+
if (p.out.length > BG_MAX_BUFFER) {
|
|
95
|
+
p.out =
|
|
96
|
+
'[...earlier output trimmed]' +
|
|
97
|
+
String.fromCharCode(10) +
|
|
98
|
+
p.out.slice(p.out.length - BG_MAX_BUFFER);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Start a shell command in the background. Returns a short id. */
|
|
102
|
+
export function startBackground(workdir, command) {
|
|
103
|
+
const id = randomBytes(3).toString('hex');
|
|
104
|
+
const shellCmd = process.platform === 'win32'
|
|
105
|
+
? process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'
|
|
106
|
+
: '/bin/sh';
|
|
107
|
+
const child = spawn(shellCmd, ['-c', command], {
|
|
108
|
+
cwd: workdir,
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
env: { ...process.env },
|
|
111
|
+
// Own process group so a kill reaches children (a dev server spawns more).
|
|
112
|
+
detached: process.platform !== 'win32',
|
|
113
|
+
});
|
|
114
|
+
const proc = {
|
|
115
|
+
id,
|
|
116
|
+
command,
|
|
117
|
+
child,
|
|
118
|
+
out: '',
|
|
119
|
+
status: 'running',
|
|
120
|
+
code: null,
|
|
121
|
+
signal: null,
|
|
122
|
+
startedAt: Date.now(),
|
|
123
|
+
};
|
|
124
|
+
const append = (chunk) => {
|
|
125
|
+
proc.out += chunk.toString();
|
|
126
|
+
bgTrim(proc);
|
|
127
|
+
};
|
|
128
|
+
child.stdout?.on('data', append);
|
|
129
|
+
child.stderr?.on('data', append);
|
|
130
|
+
child.on('exit', (code, signal) => {
|
|
131
|
+
proc.status = 'exited';
|
|
132
|
+
proc.code = code;
|
|
133
|
+
proc.signal = signal;
|
|
134
|
+
proc.out += `\n[exited code=${code} signal=${signal}]`;
|
|
135
|
+
bgTrim(proc);
|
|
136
|
+
});
|
|
137
|
+
child.on('error', (err) => {
|
|
138
|
+
proc.status = 'exited';
|
|
139
|
+
proc.out += `\n[error: ${err.message}]`;
|
|
140
|
+
});
|
|
141
|
+
BG_PROCS.set(id, proc);
|
|
142
|
+
return id;
|
|
143
|
+
}
|
|
144
|
+
/** Read the accumulated output of a background process (and its status). */
|
|
145
|
+
export function pollBackground(id) {
|
|
146
|
+
const p = BG_PROCS.get(id);
|
|
147
|
+
if (!p)
|
|
148
|
+
return `No background process with id ${id}.`;
|
|
149
|
+
const head = '[' +
|
|
150
|
+
id +
|
|
151
|
+
' ' +
|
|
152
|
+
p.status +
|
|
153
|
+
(p.status === 'exited' ? ' code=' + p.code : '') +
|
|
154
|
+
'] ' +
|
|
155
|
+
p.command;
|
|
156
|
+
const body = p.out.trim() || '(no output yet)';
|
|
157
|
+
return head + String.fromCharCode(10) + body;
|
|
158
|
+
}
|
|
159
|
+
/** Kill a background process (its whole group on POSIX). */
|
|
160
|
+
export function killBackground(id) {
|
|
161
|
+
const p = BG_PROCS.get(id);
|
|
162
|
+
if (!p)
|
|
163
|
+
return `No background process with id ${id}.`;
|
|
164
|
+
if (p.status === 'exited')
|
|
165
|
+
return `Process ${id} already exited (code=${p.code}).`;
|
|
166
|
+
try {
|
|
167
|
+
if (process.platform !== 'win32' && p.child.pid) {
|
|
168
|
+
process.kill(-p.child.pid, 'SIGTERM');
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
p.child.kill('SIGTERM');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
return `Failed to kill ${id}: ${e.message}`;
|
|
176
|
+
}
|
|
177
|
+
BG_PROCS.delete(id);
|
|
178
|
+
return `Killed background process ${id}.`;
|
|
179
|
+
}
|
|
180
|
+
/** List running background processes (for /status and cleanup). */
|
|
181
|
+
export function listBackground() {
|
|
182
|
+
return [...BG_PROCS.values()]
|
|
183
|
+
.filter((p) => p.status === 'running')
|
|
184
|
+
.map((p) => ({ id: p.id, command: p.command }));
|
|
185
|
+
}
|
|
186
|
+
/** Kill every background process — called on exit so no orphan survives. */
|
|
187
|
+
export function killAllBackground() {
|
|
188
|
+
for (const id of [...BG_PROCS.keys()])
|
|
189
|
+
killBackground(id);
|
|
190
|
+
}
|
|
90
191
|
// Run an executable directly with an argument ARRAY (no shell). Use this
|
|
91
192
|
// whenever an argument comes from the model: the shell never sees it, so
|
|
92
193
|
// $(...), backticks and quotes cannot be interpreted as commands.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Run the statusLine command and return its first stdout line (trimmed). */
|
|
3
|
+
export function runStatusLineCommand(workdir, command, info, timeoutMs = 4000) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
let child;
|
|
6
|
+
try {
|
|
7
|
+
child = spawn(command, {
|
|
8
|
+
cwd: workdir,
|
|
9
|
+
shell: process.platform === 'win32'
|
|
10
|
+
? process.env.ComSpec || true
|
|
11
|
+
: '/bin/sh',
|
|
12
|
+
windowsHide: true,
|
|
13
|
+
env: { ...process.env },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
resolve('');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let out = '';
|
|
21
|
+
let done = false;
|
|
22
|
+
const finish = () => {
|
|
23
|
+
if (done)
|
|
24
|
+
return;
|
|
25
|
+
done = true;
|
|
26
|
+
const first = String(out).split(/\r?\n/)[0].trim();
|
|
27
|
+
resolve(first);
|
|
28
|
+
};
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
try {
|
|
31
|
+
child.kill();
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
finish();
|
|
35
|
+
}, timeoutMs);
|
|
36
|
+
child.stdout?.on('data', (c) => {
|
|
37
|
+
out += c.toString();
|
|
38
|
+
});
|
|
39
|
+
child.on('error', () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
finish();
|
|
42
|
+
});
|
|
43
|
+
child.on('close', () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
finish();
|
|
46
|
+
});
|
|
47
|
+
if (child.stdin) {
|
|
48
|
+
child.stdin.on('error', () => { });
|
|
49
|
+
child.stdin.end(JSON.stringify(info) + String.fromCharCode(10));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|