zames_pro 2.71.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 +26 -0
- package/dist/agent-loop.js +255 -924
- 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 +6 -0
- package/dist/diagnostics.js +71 -0
- package/dist/hooks.js +30 -2
- package/dist/i18n/common.js +4 -0
- package/dist/index.js +49 -24
- package/dist/input.js +25 -1
- package/dist/run-task.js +31 -0
- package/dist/shell.js +102 -1
- package/dist/statusline.js +52 -0
- package/dist/subagent.js +40 -3
- package/dist/tools.js +81 -8
- 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} +41 -4
- /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/index.js
CHANGED
|
@@ -22,10 +22,13 @@ import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
|
|
|
22
22
|
import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
|
|
23
23
|
import { performCompact } from './compact.js';
|
|
24
24
|
import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
|
|
25
|
+
import { createCliArgs } from './cli-args.js';
|
|
25
26
|
import { renderMarkdown, setAnswerWidth } from './markdown.js';
|
|
27
|
+
import { runStatusLineCommand } from './statusline.js';
|
|
26
28
|
import { parseBacklogNote } from './backlog.js';
|
|
29
|
+
import { askOperatorConfirm, } from './run-task.js';
|
|
27
30
|
import { closeWeb } from './web.js';
|
|
28
|
-
import { assertCommandInsideRoot, runShell } from './shell.js';
|
|
31
|
+
import { assertCommandInsideRoot, runShell, killAllBackground, } from './shell.js';
|
|
29
32
|
import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, } from './sessions.js';
|
|
30
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
34
|
const NL = String.fromCharCode(10);
|
|
@@ -48,28 +51,9 @@ function authMarkerExists() {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
// ---------- CLI parsing ----------
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
|
|
55
|
-
}
|
|
56
|
-
function hasFlag(flag) {
|
|
57
|
-
return args.includes(flag);
|
|
58
|
-
}
|
|
59
|
-
function getPositional() {
|
|
60
|
-
const positional = [];
|
|
61
|
-
for (let i = 0; i < args.length; i++) {
|
|
62
|
-
const a = args[i];
|
|
63
|
-
if (['--dir', '--task', '--max-iter', '--chat', '--output-format'].includes(a)) {
|
|
64
|
-
i++;
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
if (a.startsWith('--'))
|
|
68
|
-
continue;
|
|
69
|
-
positional.push(a);
|
|
70
|
-
}
|
|
71
|
-
return positional;
|
|
72
|
-
}
|
|
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));
|
|
73
57
|
// /compact asks the OLD chat for a summary. If browser.ask() exhausts its own
|
|
74
58
|
// rate-limit budget, aborting the whole compaction used to force the operator
|
|
75
59
|
// to start over. We retry the summary call a few times instead (inside
|
|
@@ -1146,7 +1130,20 @@ async function main() {
|
|
|
1146
1130
|
maxSubagents: config.browser.maxSubagents,
|
|
1147
1131
|
askDeadlineMs: config.browser.askDeadlineMs,
|
|
1148
1132
|
maxAfterToolRetries: config.browser.maxAfterToolRetries,
|
|
1149
|
-
|
|
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
|
+
},
|
|
1150
1147
|
})
|
|
1151
1148
|
: null;
|
|
1152
1149
|
// A compact "tasks: 2/5" summary for the status line. Empty when the list
|
|
@@ -1245,6 +1242,8 @@ async function main() {
|
|
|
1245
1242
|
let reviewMode = null;
|
|
1246
1243
|
process.on('SIGINT', async () => {
|
|
1247
1244
|
running = false;
|
|
1245
|
+
// Kill any background processes (N33) so no dev-server outlives the agent.
|
|
1246
|
+
killAllBackground();
|
|
1248
1247
|
await browser.close().catch(() => { });
|
|
1249
1248
|
// Close the lazy headless browser from web.js, otherwise it would stay
|
|
1250
1249
|
// as a separate process after the agent exits.
|
|
@@ -1297,6 +1296,8 @@ async function main() {
|
|
|
1297
1296
|
// the typed text is never overwritten by output. In non-TTY (pipe) —
|
|
1298
1297
|
// the old promptOnce.
|
|
1299
1298
|
let editor = null;
|
|
1299
|
+
// N37 statusLine hook timer (cleared on exit).
|
|
1300
|
+
let statusLineTimer = null;
|
|
1300
1301
|
let waiter = null;
|
|
1301
1302
|
const takeInput = () => {
|
|
1302
1303
|
if (pendingQueue.length)
|
|
@@ -1347,6 +1348,28 @@ async function main() {
|
|
|
1347
1348
|
// Toggle icons (🧠 deep thinking, 🌐 web search) before the context
|
|
1348
1349
|
// counter. The editor pulls the live state on every render.
|
|
1349
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
|
+
}
|
|
1350
1373
|
ed.onAttach = async (raw) => {
|
|
1351
1374
|
// Case 1: the paste is the image data itself (data URL / base64 blob).
|
|
1352
1375
|
const image = parseImagePaste(raw);
|
|
@@ -3501,6 +3524,8 @@ async function main() {
|
|
|
3501
3524
|
editor.dispose();
|
|
3502
3525
|
if (scheduleTimer)
|
|
3503
3526
|
clearInterval(scheduleTimer);
|
|
3527
|
+
if (statusLineTimer)
|
|
3528
|
+
clearInterval(statusLineTimer);
|
|
3504
3529
|
await browser.close().catch(() => { });
|
|
3505
3530
|
await mod.closeWeb().catch(() => { });
|
|
3506
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
|
@@ -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,
|
|
@@ -359,6 +361,9 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
359
361
|
getTokenUsage,
|
|
360
362
|
selfImprovement,
|
|
361
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,
|
|
362
367
|
});
|
|
363
368
|
// The loop may end WITHOUT a model answer: an exhausted iteration limit
|
|
364
369
|
// or an ask() watchdog. Surface it instead of a silent stop.
|
|
@@ -368,6 +373,14 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
368
373
|
ui.warning(outcome);
|
|
369
374
|
transcript?.log('agent_no_answer', { outcome });
|
|
370
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';
|
|
371
384
|
}
|
|
372
385
|
// T7: one summary line per task (duration + tool calls + tokens spent).
|
|
373
386
|
// Printed even on an abort, so the operator sees what happened.
|
|
@@ -433,6 +446,23 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
433
446
|
sendSystemPrompt: false,
|
|
434
447
|
};
|
|
435
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
|
+
});
|
|
436
466
|
return failure ? { ok: false, error: failure } : { ok: true };
|
|
437
467
|
}
|
|
438
468
|
catch (e) {
|
|
@@ -441,6 +471,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
|
|
|
441
471
|
if (debug)
|
|
442
472
|
console.error(e.stack);
|
|
443
473
|
transcript?.log('agent_error', { error: e.message });
|
|
474
|
+
transcript?.log('task_result', { ok: false, outcome: 'error' });
|
|
444
475
|
return { ok: false, error: e.message };
|
|
445
476
|
}
|
|
446
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
|
+
}
|
package/dist/subagent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadHooks, runLifecycleHooks } from './hooks.js';
|
|
1
2
|
// Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
|
|
2
3
|
// by the agent loop as a SEAM (like onAutoCompact): it calls back into the
|
|
3
4
|
// caller, which owns the whole lifecycle. This module builds that callback.
|
|
@@ -13,6 +14,19 @@
|
|
|
13
14
|
// browser.ts, which owns the live Playwright context) — out of scope here.
|
|
14
15
|
/** Hard cap on iterations for one subagent run. */
|
|
15
16
|
export const SUBAGENT_MAX_ITER = 30;
|
|
17
|
+
// The report is fed back into the PARENT chat as a tool result, so a runaway
|
|
18
|
+
// subagent must not be able to blow up the parent's context with one answer.
|
|
19
|
+
// The tail is trimmed with a marker, not silently dropped.
|
|
20
|
+
export const SUBAGENT_REPORT_CAP = 20_000;
|
|
21
|
+
/** Cap a subagent report before it becomes a parent-context tool result. */
|
|
22
|
+
export function capReport(text, cap = SUBAGENT_REPORT_CAP) {
|
|
23
|
+
if (text.length <= cap)
|
|
24
|
+
return text;
|
|
25
|
+
return (text.slice(0, cap) +
|
|
26
|
+
'\n… [report truncated: ' +
|
|
27
|
+
(text.length - cap) +
|
|
28
|
+
' chars omitted]');
|
|
29
|
+
}
|
|
16
30
|
/**
|
|
17
31
|
* The subagent persona, prepended to the task message. It is deliberately NOT
|
|
18
32
|
* injected as a replacement system prompt: the nested runAgentLoop builds the
|
|
@@ -43,7 +57,7 @@ export function subagentPersona(type) {
|
|
|
43
57
|
* returns only the report.
|
|
44
58
|
*/
|
|
45
59
|
export function createSubagentRunner(opts) {
|
|
46
|
-
const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries,
|
|
60
|
+
const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, onAskPermission, } = opts;
|
|
47
61
|
let used = 0;
|
|
48
62
|
return async (req) => {
|
|
49
63
|
if (used >= maxSubagents) {
|
|
@@ -84,11 +98,24 @@ export function createSubagentRunner(opts) {
|
|
|
84
98
|
locale,
|
|
85
99
|
askDeadlineMs,
|
|
86
100
|
maxAfterToolRetries,
|
|
87
|
-
|
|
101
|
+
// A subagent NEVER touches BACKLOG.md, even in the operator's dev
|
|
102
|
+
// mode: its sub-task is scoped, and a general subagent appending
|
|
103
|
+
// "improvement" notes would be unrelated, unsupervised edits.
|
|
104
|
+
selfImprovement: false,
|
|
88
105
|
// No recursion: the subagent cannot spawn its own subagents.
|
|
89
106
|
onSubagent: null,
|
|
90
107
|
// Do not persist the subagent chat as a session.
|
|
91
108
|
onChatReady: () => { },
|
|
109
|
+
// Keep the PARENT's UI alive during the nested run. The nested loop
|
|
110
|
+
// OVERWRITES browser.onSendStart/… with its own callbacks; without
|
|
111
|
+
// forwarding the parent's hooks the spinner and the throttle countdown
|
|
112
|
+
// would go blank for the whole subagent run (a long subagent looked
|
|
113
|
+
// like a frozen, dead agent). The nested loop, not this runner, owns
|
|
114
|
+
// the assignment, so we hand it the functions it should install.
|
|
115
|
+
onThinking: savedHooks.onSendStart ?? undefined,
|
|
116
|
+
onSendPause: savedHooks.onSendPause ?? undefined,
|
|
117
|
+
onSendState: savedHooks.onSendState ?? undefined,
|
|
118
|
+
onNotice: savedHooks.onNotice ?? undefined,
|
|
92
119
|
onAskPermission: onAskPermission
|
|
93
120
|
? (info) => onAskPermission({
|
|
94
121
|
tool: info.tool,
|
|
@@ -112,8 +139,18 @@ export function createSubagentRunner(opts) {
|
|
|
112
139
|
browser.onSendState = savedHooks.onSendState;
|
|
113
140
|
browser.onNotice = savedHooks.onNotice;
|
|
114
141
|
}
|
|
142
|
+
// SubagentStop hook (N32): fires after the nested loop finishes, so a hook
|
|
143
|
+
// can clean up or notify. Best-effort; its stdout is appended to the report.
|
|
144
|
+
let hookOut = '';
|
|
145
|
+
try {
|
|
146
|
+
hookOut = await runLifecycleHooks(loadHooks(workdir), 'SubagentStop', workdir, { type: req.type, ok: failed ? 'false' : 'true' });
|
|
147
|
+
}
|
|
148
|
+
catch { }
|
|
115
149
|
if (failed)
|
|
116
150
|
return { ok: false, text: failed };
|
|
117
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
text: capReport(hookOut ? report + '\n\n' + hookOut : report),
|
|
154
|
+
};
|
|
118
155
|
};
|
|
119
156
|
}
|