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.
@@ -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.',
@@ -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
- const args = process.argv.slice(2);
51
- function getArg(flag, fallback = null) {
52
- const i = args.indexOf(flag);
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._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
@@ -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
+ }