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.
@@ -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.
@@ -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, selfImprovement = false, onAskPermission, } = opts;
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
- selfImprovement,
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 { ok: true, text: report };
151
+ return {
152
+ ok: true,
153
+ text: capReport(hookOut ? report + '\n\n' + hookOut : report),
154
+ };
118
155
  };
119
156
  }