zames_pro 2.71.0 → 2.72.1

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/dist/changelog.js CHANGED
@@ -78,3 +78,32 @@ export function renderChangelogDraft(groups) {
78
78
  }
79
79
  return out.join('\n').trimEnd();
80
80
  }
81
+ /**
82
+ * Replace the body between `## [Unreleased]` and the next `## [` heading with
83
+ * a freshly drafted body (D2). PURE so a test can pin the exact behavior.
84
+ *
85
+ * When `body` is empty the existing Unreleased section is CLEARED to just the
86
+ * heading (no stray blank content). A changelog without an Unreleased heading
87
+ * is returned UNCHANGED (we never guess where to insert one).
88
+ */
89
+ export function syncUnreleased(changelog, body) {
90
+ const nl = '\n';
91
+ const anchor = '## [Unreleased]';
92
+ const idx = changelog.indexOf(anchor);
93
+ if (idx < 0)
94
+ return changelog;
95
+ const afterAnchor = idx + anchor.length;
96
+ // The section ends at the next top-level `## [` (the next version).
97
+ const rest = changelog.slice(afterAnchor);
98
+ const next = rest.search(/\n## \[/);
99
+ const tail = next < 0 ? '' : rest.slice(next);
100
+ const cleanBody = String(body || '')
101
+ .replace(/^## \[Unreleased\]\s*/m, '')
102
+ .trim()
103
+ // The draft renders groups as `## Added` (top-level), but INSIDE the file
104
+ // a version's groups are `### Added` (a subsection of the version heading).
105
+ // `## [` (the next version) is left alone.
106
+ .replace(/^## (?!\[)/gm, '### ');
107
+ const section = cleanBody ? nl + nl + cleanBody + nl : nl;
108
+ return changelog.slice(0, afterAnchor) + section + tail;
109
+ }
@@ -0,0 +1,48 @@
1
+ // PURE CLI argument parsing (BACKLOG C3). index.ts used to hold `args` as a
2
+ // module-level constant plus three helpers closing over it, which made the
3
+ // parsing impossible to unit-test in isolation (index.ts is the entry point and
4
+ // is not covered). `createCliArgs(argv)` binds the argv ONCE and returns the
5
+ // same three helpers, so every call site in index.ts keeps its shape
6
+ // (`getArg('--x')` / `hasFlag('--x')`) while the LOGIC is now pure and tested.
7
+ //
8
+ // Behavior is intentionally identical to the old inline helpers, including the
9
+ // quirk that a value-flag appearing WITHOUT a value returns the fallback (never
10
+ // the flag itself or the next flag).
11
+ // Flags that consume the NEXT argv entry as their value, so the positional
12
+ // parser must skip it. Kept in sync with the getArg call sites by
13
+ // test/cli-flags.test.ts.
14
+ export const SKIP_VALUE_FLAGS = [
15
+ '--dir',
16
+ '--task',
17
+ '--max-iter',
18
+ '--chat',
19
+ '--output-format',
20
+ ];
21
+ export function createCliArgs(argv) {
22
+ const args = Array.isArray(argv) ? argv : [];
23
+ const getArg = (flag, fallback = null) => {
24
+ const i = args.indexOf(flag);
25
+ // `args[i + 1]` truthiness means an empty string or a missing value falls
26
+ // back — the historical behavior (an empty `--task ''` is not a task).
27
+ // NOTE: this does NOT special-case a following FLAG, so `--task --debug`
28
+ // returns `--debug` (the original inline helper did the same); we preserve
29
+ // it rather than "fix" a long-standing quirk in a refactor.
30
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
31
+ };
32
+ const hasFlag = (flag) => args.includes(flag);
33
+ const getPositional = () => {
34
+ const positional = [];
35
+ for (let i = 0; i < args.length; i++) {
36
+ const a = args[i];
37
+ if (SKIP_VALUE_FLAGS.includes(a)) {
38
+ i++;
39
+ continue;
40
+ }
41
+ if (a.startsWith('--'))
42
+ continue;
43
+ positional.push(a);
44
+ }
45
+ return positional;
46
+ };
47
+ return { getArg, hasFlag, getPositional };
48
+ }
package/dist/compact.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { translate } from './i18n.js';
2
2
  import { buildCompactPrompt, buildCompactCarryover, isUsableCompactSummary, trimRestoredMessages, formatRestoredHistory, } from './commands.js';
3
+ import { loadHooks, runLifecycleHooks } from './hooks.js';
3
4
  const NL = String.fromCharCode(10);
4
5
  /** How many times the summary call is retried before the local fallback. */
5
6
  export const COMPACT_SUMMARY_ATTEMPTS = 3;
@@ -42,6 +43,14 @@ export async function performCompact(opts) {
42
43
  try {
43
44
  if (!quiet)
44
45
  log(t('compact.start'));
46
+ // PreCompact hook (N32): runs BEFORE the chat is summarized/rewritten, so
47
+ // a hook can persist state or notify. Best-effort.
48
+ try {
49
+ const out = await runLifecycleHooks(loadHooks(workdir), 'PreCompact', workdir, { chatId: currentChatId });
50
+ if (out)
51
+ transcript?.log('hook_pre_compact', { output: out });
52
+ }
53
+ catch { }
45
54
  // 1) Ask the OLD chat to summarize itself.
46
55
  let summary = '';
47
56
  let summaryErr = '';
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { DEFAULT_DIAGNOSTICS } from './diagnostics.js';
4
5
  import { DEFAULT_LOCALE } from './i18n.js';
5
6
  import { writeJsonAtomic } from './fsutil.js';
6
7
  const ZAMES_HOME = path.join(os.homedir(), '.zames');
@@ -67,7 +68,12 @@ export const DEFAULTS = {
67
68
  contextLimit: 1_000_000,
68
69
  // 0 = auto: terminal width, capped at 100 columns (the historical width).
69
70
  answerWidth: 0,
71
+ // N37 statusLine hook. Off by default; edit config.json to set a command.
72
+ statusLineCommand: '',
73
+ statusLineIntervalMs: 10_000,
70
74
  },
75
+ // Off by default: a full tsc/eslint run after every edit is slow; opt in.
76
+ diagnostics: { ...DEFAULT_DIAGNOSTICS },
71
77
  };
72
78
  export function loadConfig() {
73
79
  const merged = JSON.parse(JSON.stringify(DEFAULTS));
@@ -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 hooks?.PostToolUse ?? []) {
173
+ for (const entry of list ?? []) {
146
174
  if (!matchesHook(entry, tool))
147
175
  continue;
148
- const r = await runHook(entry, 'PostToolUse', tool, args, workdir, result);
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);
@@ -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
- const args = process.argv.slice(2);
52
- function getArg(flag, fallback = null) {
53
- const i = args.indexOf(flag);
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
- selfImprovement: devMode,
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._queueBadge() + this._tasksBadge() + this._toggleIcons() + ctxRaw;
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.