@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
915 lines (914 loc) • 38.3 kB
JavaScript
/**
* Background commands (N4) — run a command detached, bank every byte it
* writes, monitor it while it runs.
*
* A reviewing agent has to run real commands — a build, a test suite, a linter
* whose output IS the evidence for a finding — and the naive shapes both fail.
* `bashTool` blocks the loop, hands the model a shell, and truncates its own
* output at 100 KB. A `child_process` call with a string command is a shell
* injection with extra steps.
*
* This module keeps three promises instead:
*
* - **Detached.** `startBackgroundCommand` returns a `taskId` immediately; the
* agent keeps working and asks about the command when it wants to.
* - **Nothing discarded.** Both streams are written straight to files as they
* arrive and the COMPLETE files are banked as artifacts (N3) when the
* command settles. The conversation gets a bounded tail plus a read-back
* call. The single bound is `maxOutputBytes`, and reaching it is a state
* (`output-limit`) the caller can see, not a silent cut.
* - **Hardened by contract.** argv arrays with `shell: false`, an exact-match
* executable allowlist, a cwd that must resolve through symlinks inside a
* declared root, and a timeout that escalates SIGTERM → SIGKILL.
*
* Completion reaches the model the same way a delegate's does (N2.3): the
* `running` / `finished` counters ride on every command tool result and — via
* the checklist — on every `tasks_list`. The core generate loop is untouched.
*
* @module agent/backgroundCommands
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { z } from "zod";
import { logger } from "../utils/logger.js";
import { resolveWithinRoot } from "../utils/pathSandbox.js";
import { resolveChecklistSessionId, setChecklistCommandCountsSource, } from "./taskChecklist.js";
/**
* Every command this process started, keyed by taskId.
*
* Module-level for the same reason the checklist and the delegation registry
* are: a command outlives the tool call that started it, and compaction — which
* only rewrites messages — cannot touch a module map. Settled jobs are NOT
* evicted: their log files and banked artifacts are the run's evidence, and a
* status call that answers "unknown taskId" for a command that ran is exactly
* the information loss this primitive exists to prevent.
*/
const commands = new Map();
const hostPolicies = new WeakMap();
/** Wall-clock budget when neither the caller nor the policy names one. */
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
/** Per-stream byte cap when neither the caller nor the policy names one. */
const DEFAULT_MAX_OUTPUT_BYTES = 10_485_760;
/** How long a SIGTERMed process has to unwind before SIGKILL. */
const SIGKILL_GRACE_MS = 5_000;
/** Hard bound on `tailPreview`, whatever the streams hold. */
const TAIL_PREVIEW_CHARS = 2_000;
/**
* Raw bytes kept per stream for the tail. Four bytes per character is the
* UTF-8 worst case, so this can always produce a full-length preview.
*/
const TAIL_BUFFER_BYTES = TAIL_PREVIEW_CHARS * 4;
/** Preview cut into the conversation from each banked stream. */
const OUTPUT_BANK_PREVIEW_CHARS = 600;
/** Characters returned by one `command_output` page when none is asked for. */
const DEFAULT_OUTPUT_PAGE_CHARS = 50_000;
/** Ceiling on one page, however much the caller asks for. */
const MAX_OUTPUT_PAGE_CHARS = 200_000;
/** Longest label derived from an argv. */
const LABEL_MAX_CHARS = 60;
/** Directory under the OS temp dir that holds every command's logs. */
const COMMAND_LOG_DIR = "neurolink-commands";
/**
* Characters that only mean something to a shell.
*
* argv[0] is executed directly — there is no shell to interpret them — so
* their presence means the caller believed it was writing a shell command
* line. Refusing loudly is far kinder than spawning an executable literally
* named `sh -c rm -rf /`, which is what would otherwise happen.
*/
const SHELL_METACHARACTERS = /[;&|<>$`\n\r]/;
const STREAM_NAMES = [
"stdout",
"stderr",
];
let commandCounter = 0;
let checklistCountsInstalled = false;
// ── Small helpers ──────────────────────────────────────────────────────────
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
/** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
function refusal(message) {
return { isError: true, error: message };
}
function bounded(text, maxChars) {
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
}
function labelFor(argv, explicit) {
const chosen = explicit?.trim() || argv.slice(0, 2).join(" ") || argv[0] || "command";
return bounded(chosen, LABEL_MAX_CHARS);
}
function emptyStream(path) {
return {
path,
bytes: 0,
tailBytes: Buffer.alloc(0),
limitReached: false,
};
}
/**
* Decode a byte tail. The window starts wherever the stream happened to be,
* so it may open mid-character; the replacement character that produces is
* dropped rather than shown.
*/
function decodeTail(bytes) {
const text = bytes.toString("utf-8");
const trimmed = text.startsWith("\uFFFD") ? text.slice(1) : text;
return trimmed.slice(-TAIL_PREVIEW_CHARS);
}
// ── Policy ─────────────────────────────────────────────────────────────────
/**
* Declare what this host may execute. There is no default: until this is
* called, every start is refused, because "run whatever the model asks" is not
* a defensible default for a primitive that spawns processes.
*/
export function setBackgroundCommandPolicy(host, policy) {
hostPolicies.set(host, policy);
installChecklistCounts();
logger.debug("[BackgroundCommands] Policy set", {
allowedExecutables: policy.allowedExecutables.length,
cwdRoot: policy.cwdRoot,
});
}
/** The policy in force for a host, or undefined when none was declared. */
export function getBackgroundCommandPolicy(host) {
return hostPolicies.get(host);
}
const NO_POLICY_REFUSAL = "No background-command policy is set on this instance, so nothing may be executed. " +
"The host must call setBackgroundCommandPolicy({ allowedExecutables, cwdRoot }) — " +
"or registerBackgroundCommandTools(policy) — before any command can start. Do the " +
"work with your other tools instead.";
// ── Counting ───────────────────────────────────────────────────────────────
function isSettled(job) {
return job.settledAt !== undefined;
}
function tally(candidates) {
let running = 0;
let finished = 0;
for (const job of candidates) {
if (!isSettled(job)) {
running += 1;
}
else if (!job.acknowledged) {
finished += 1;
}
}
return { running, finished };
}
/**
* Hand out a job's status and record that someone has now seen it.
*
* A settled command stays in the registry forever — its logs and artifacts are
* the run's evidence — so "finished" has to mean "finished and unread" or the
* counter only ever climbs and stops meaning anything. Nothing is dropped when
* it clears; only the flag moves.
*/
function acknowledge(job) {
if (isSettled(job)) {
job.acknowledged = true;
}
return statusOf(job);
}
/** Counts across every host for one session — what the checklist reads. */
function countsForSession(sessionId) {
return tally([...commands.values()].filter((job) => job.sessionId === sessionId));
}
/**
* Commands for a host's session: `running` have not settled, `finished` have.
* Carried on every command tool result and on every `ChecklistToolResult`, so
* the model learns a build finished without polling for it.
*/
export function backgroundCommandCounts(host, sessionId) {
const session = sessionId ?? resolveChecklistSessionId(host);
return tally([...commands.values()].filter((job) => job.host === host && job.sessionId === session));
}
/**
* Feed the task checklist's command counters, once per process — the N2.3
* notification channel, reused rather than rebuilt.
*/
function installChecklistCounts() {
if (checklistCountsInstalled) {
return;
}
setChecklistCommandCountsSource(countsForSession);
checklistCountsInstalled = true;
}
// ── Validation ─────────────────────────────────────────────────────────────
/**
* Everything that must hold before a process is created. Returns the refusal
* reason, or undefined when the start may proceed.
*
* Each check names its own recovery step: a refusal the model cannot act on
* just becomes a retry of the same call.
*/
function validateArgv(argv, policy) {
if (!Array.isArray(argv) || argv.length === 0) {
return ("A command needs a non-empty argv array: the executable first, then one array " +
'entry per argument — ["pnpm", "run", "lint"], never a single command string.');
}
if (argv.some((part) => typeof part !== "string")) {
return "Every argv entry must be a string. Pass each argument as its own entry.";
}
if (argv.some((part) => part.includes("\0"))) {
return "argv entries must not contain NUL bytes. Remove it and retry.";
}
const executable = argv[0];
if (SHELL_METACHARACTERS.test(executable) || /\s/.test(executable)) {
return (`"${bounded(executable, 80)}" is not an executable name. Commands run with NO shell, ` +
"so pipes, redirects, semicolons and quoting do nothing — put the executable in " +
"argv[0] and every argument in its own entry. If you need a pipeline, run the " +
"steps as separate commands.");
}
if (!policy.allowedExecutables.includes(executable)) {
const allowed = policy.allowedExecutables.join(", ") || "(none)";
return (`Executable "${executable}" is not allowed here. Permitted executables are: ${allowed}. ` +
"Use one of those, or do the work with your other tools.");
}
return undefined;
}
// ── Job bookkeeping ────────────────────────────────────────────────────────
function jobFor(host, taskId) {
const job = commands.get(taskId);
if (!job || job.host !== host) {
const known = [...commands.values()]
.filter((candidate) => candidate.host === host)
.map((candidate) => candidate.taskId);
throw new Error(known.length > 0
? `No background command "${taskId}". Known task ids are ${known.join(", ")} — ` +
"use one of those."
: `No background command "${taskId}": this instance has not started any. ` +
"Start one with run_command_bg first.");
}
return job;
}
function renderTail(job) {
const out = decodeTail(job.streams.stdout.tailBytes);
const err = decodeTail(job.streams.stderr.tailBytes);
if (!out && !err) {
return isSettled(job) ? "(the command wrote no output)" : "(no output yet)";
}
const budget = out && err ? Math.floor(TAIL_PREVIEW_CHARS / 2) : TAIL_PREVIEW_CHARS;
const parts = [];
if (out) {
parts.push(`[stdout tail]\n${out.slice(-budget)}`);
}
if (err) {
parts.push(`[stderr tail]\n${err.slice(-budget)}`);
}
return parts.join("\n");
}
/** The public view of a job, built fresh on every read so it is never stale. */
function statusOf(job) {
return {
taskId: job.taskId,
label: job.label,
state: job.state,
...(job.exitCode !== undefined && { exitCode: job.exitCode }),
...(job.signal && { signal: job.signal }),
durationMs: (job.settledAt ?? Date.now()) - job.startedAt,
stdoutBytes: job.streams.stdout.bytes,
stderrBytes: job.streams.stderr.bytes,
...(job.streams.stdout.banked && { stdout: job.streams.stdout.banked }),
...(job.streams.stderr.banked && { stderr: job.streams.stderr.banked }),
tailPreview: renderTail(job),
...(job.error && { error: job.error }),
};
}
/**
* Bank one stream's log file and hand back the pointer.
*
* A banking failure is reported in the reference rather than thrown: losing
* the artifact must not also lose the command's outcome, and the caller is
* told in so many words that the read-back is unavailable and why — the log
* file itself is still on disk at the path named in the hint.
*/
async function bankStream(job, name) {
const stream = job.streams[name];
const label = `${job.label} [${name}]`;
let content = "";
try {
content = await readFile(stream.path, "utf-8");
}
catch (error) {
logger.warn("[BackgroundCommands] Reading the stream log failed", {
taskId: job.taskId,
stream: name,
error: errorMessage(error),
});
}
try {
return await job.host.bankArtifact(content, {
kind: "command-output",
label,
sessionId: job.sessionId,
previewChars: OUTPUT_BANK_PREVIEW_CHARS,
});
}
catch (error) {
const message = errorMessage(error);
logger.warn("[BackgroundCommands] Banking the command output failed", {
taskId: job.taskId,
stream: name,
error: message,
});
return {
artifactId: "",
label,
kind: "command-output",
sizeBytes: stream.bytes,
preview: bounded(content, OUTPUT_BANK_PREVIEW_CHARS),
readBackHint: `The ${name} artifact could NOT be created (${message}), so retrieve_context has ` +
`nothing to read. The complete log is still on disk at ${stream.path}.`,
};
}
}
// ── Starting a command ─────────────────────────────────────────────────────
function resolveState(job, reason) {
if (reason === "timeout") {
return "timeout";
}
if (reason === "output-limit") {
return "output-limit";
}
// sink-error included: if the child exits on its own before the SIGTERM
// lands, `job.signal` stays unset and the job would settle as a clean
// `exited` — with a banked log the failed sink silently truncated.
if (reason === "killed" || reason === "aborted" || reason === "sink-error") {
return "killed";
}
// Nobody here asked for it: an external signal still ended the process.
return job.signal ? "killed" : "exited";
}
function endWriteStream(stream) {
return new Promise((resolve) => {
// An errored sink auto-destroys, and end() on a destroyed stream never
// calls back — finish() must not hang on it.
if (stream.destroyed) {
resolve();
return;
}
stream.end(() => resolve());
});
}
/**
* Pipe one stream to its log file while counting bytes, keeping a UTF-8-safe
* rolling tail, and enforcing the byte cap.
*
* At the cap, everything up to it stays on disk in full and the command is
* killed with state `output-limit` — a loud stop, never a silent truncation
* that leaves the caller believing it read the whole thing.
*/
function attachStream(source, state, sink, maxOutputBytes, onLimit) {
source.on("data", (chunk) => {
if (state.limitReached) {
return;
}
const room = maxOutputBytes - state.bytes;
const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
if (slice.length > 0) {
state.bytes += slice.length;
state.tailBytes = Buffer.concat([state.tailBytes, slice]).subarray(-TAIL_BUFFER_BYTES);
if (!sink.write(slice)) {
source.pause();
sink.once("drain", () => source.resume());
}
}
// Strict: a chunk that exactly fills the remaining room is complete
// output, not overflow. When room hits 0, any later non-empty chunk is
// still `> room`, so real overflow is never missed.
if (chunk.length > room) {
state.limitReached = true;
onLimit();
}
});
source.on("error", (error) => {
logger.warn("[BackgroundCommands] Output stream error", {
error: errorMessage(error),
});
});
}
/**
* Start a command with an explicit policy, bypassing the host's own.
*
* The git toolset uses this so registering read-only git tools never widens
* what `run_command_bg` may execute, and never requires the host to declare a
* general command policy at all.
*
* @internal
*/
export async function startCommandWithPolicy(host, argv, options, policy) {
const invalid = validateArgv(argv, policy);
if (invalid) {
throw new Error(invalid);
}
const sandboxed = resolveWithinRoot(options.cwd, policy.cwdRoot);
if (sandboxed.error !== undefined) {
throw new Error(sandboxed.error);
}
const cwd = sandboxed.path;
const vetoed = policy.allowlist?.(argv, cwd);
if (typeof vetoed === "string") {
throw new Error(vetoed);
}
installChecklistCounts();
commandCounter += 1;
const taskId = `c${commandCounter}${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const dir = join(tmpdir(), COMMAND_LOG_DIR, taskId);
await mkdir(dir, { recursive: true, mode: 0o700 });
const startedAt = Date.now();
const timeoutMs = options.timeoutMs ?? policy.defaultTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
const maxOutputBytes = options.maxOutputBytes ?? policy.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
let settleJob = () => undefined;
const settled = new Promise((resolvePromise) => {
settleJob = resolvePromise;
});
const job = {
taskId,
host,
sessionId: options.sessionId ?? resolveChecklistSessionId(host),
label: labelFor(argv, options.label),
argv: [...argv],
cwd,
state: "queued",
startedAt,
acknowledged: false,
streams: {
stdout: emptyStream(join(dir, "stdout.log")),
stderr: emptyStream(join(dir, "stderr.log")),
},
maxOutputBytes,
timeoutMs,
settled,
};
commands.set(taskId, job);
runCommand(job, options, settleJob);
logger.debug("[BackgroundCommands] Command started", {
taskId,
label: job.label,
cwd,
timeoutMs,
});
return { taskId, argv: [...argv], startedAt };
}
/**
* Spawn the child and wire up everything that can end it. Never throws: a
* failure to spawn settles the job with the reason, because a command that
* vanishes is a command the agent waits on forever.
*/
function runCommand(job, options, settleJob) {
const outSink = createWriteStream(job.streams.stdout.path, { mode: 0o600 });
const errSink = createWriteStream(job.streams.stderr.path, { mode: 0o600 });
// A sink failure (ENOSPC, EACCES, a vanished parent directory) is emitted as
// an asynchronous 'error' event; with no listener that is an uncaught
// exception and a process crash. It settles the job instead: the command is
// killed and the failure is named on `job.error`.
const onSinkError = (name) => (error) => {
job.error ??= `The ${name} sink failed: ${errorMessage(error)}`;
terminate("sink-error");
};
outSink.on("error", onSinkError("stdout"));
errSink.on("error", onSinkError("stderr"));
// Declared before the spawn attempt: a spawn that throws settles the job
// through the same path as one that runs, and that path reads these.
let finished = false;
let terminationReason;
// Grouped so both are reachable from the settle path no matter which of the
// three ways of ending a command got there first.
const timers = {};
let child;
try {
child = spawn(job.argv[0], job.argv.slice(1), {
cwd: job.cwd,
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
...(options.env && { env: options.env }),
});
}
catch (error) {
job.error = `The command could not be started: ${errorMessage(error)}`;
void finish(undefined);
return;
}
function terminate(reason, signal = "SIGTERM") {
if (terminationReason || finished) {
return;
}
terminationReason = reason;
try {
child.kill(signal);
}
catch (error) {
logger.warn("[BackgroundCommands] Signalling the command failed", {
taskId: job.taskId,
error: errorMessage(error),
});
}
// A process that ignores SIGTERM must not be able to outlive its budget.
timers.kill = setTimeout(() => {
try {
child.kill("SIGKILL");
}
catch {
/* already gone */
}
}, SIGKILL_GRACE_MS);
timers.kill.unref?.();
}
job.terminate = terminate;
async function finish(reason) {
if (finished) {
return;
}
finished = true;
clearTimeout(timers.timeout);
clearTimeout(timers.kill);
job.detachParent?.();
await Promise.all([endWriteStream(outSink), endWriteStream(errSink)]);
job.settledAt = Date.now();
job.state = resolveState(job, reason);
for (const name of STREAM_NAMES) {
job.streams[name].banked = await bankStream(job, name);
}
if (!job.error && job.streams.stdout.limitReached) {
job.error =
`stdout reached the ${job.maxOutputBytes}-byte cap and the command was killed. ` +
"Everything written up to the cap is banked in full.";
}
else if (!job.error && job.streams.stderr.limitReached) {
job.error =
`stderr reached the ${job.maxOutputBytes}-byte cap and the command was killed. ` +
"Everything written up to the cap is banked in full.";
}
const status = statusOf(job);
logger.debug("[BackgroundCommands] Command settled", {
taskId: job.taskId,
state: status.state,
exitCode: status.exitCode,
durationMs: status.durationMs,
});
settleJob(status);
}
if (child.stdout) {
attachStream(child.stdout, job.streams.stdout, outSink, job.maxOutputBytes, () => terminate("output-limit"));
}
if (child.stderr) {
attachStream(child.stderr, job.streams.stderr, errSink, job.maxOutputBytes, () => terminate("output-limit"));
}
child.on("spawn", () => {
if (job.state === "queued") {
job.state = "running";
}
});
child.on("error", (error) => {
job.error = `The command could not be started: ${error.message}`;
void finish(terminationReason);
});
child.on("close", (code, signal) => {
if (code !== null) {
job.exitCode = code;
}
if (signal) {
job.signal = signal;
}
void finish(terminationReason);
});
timers.timeout = setTimeout(() => {
job.error =
`The command exceeded its ${job.timeoutMs}ms budget and was killed. Output up to ` +
"that point is banked in full.";
terminate("timeout");
}, job.timeoutMs);
timers.timeout.unref?.();
const parentSignal = options.abortSignal;
if (parentSignal) {
if (parentSignal.aborted) {
terminate("aborted");
}
else {
const onAbort = () => terminate("aborted");
parentSignal.addEventListener("abort", onAbort, { once: true });
job.detachParent = () => parentSignal.removeEventListener("abort", onAbort);
}
}
}
/**
* Start a command in the background and get its task id immediately.
*
* @throws when no policy is set, argv is malformed, the executable is not
* allowlisted, the policy vetoes the command, or the cwd escapes the
* sandbox root. Every message names its own recovery step.
*/
export async function startBackgroundCommand(host, argv, options) {
const policy = hostPolicies.get(host);
if (!policy) {
throw new Error(NO_POLICY_REFUSAL);
}
return startCommandWithPolicy(host, argv, options, policy);
}
// ── Monitoring ─────────────────────────────────────────────────────────────
/**
* Everything known about one command right now — synchronous, because the
* job state is live and a monitor that has to be awaited is a monitor nobody
* calls mid-loop.
*
* @throws when the task id is unknown to this host
*/
export function getBackgroundCommandStatus(host, taskId) {
return acknowledge(jobFor(host, taskId));
}
function afterMs(ms) {
return new Promise((resolve) => {
const timer = setTimeout(resolve, Math.max(0, ms));
timer.unref?.();
});
}
/**
* Wait for a command to settle.
*
* `timeoutMs` bounds the WAIT, not the command: when it elapses the current
* (still running) status is returned rather than throwing, so a caller can
* poll in bounded steps without ever losing the job.
*
* @throws when the task id is unknown to this host
*/
export async function awaitBackgroundCommand(host, taskId, opts) {
const job = jobFor(host, taskId);
if (!isSettled(job)) {
if (opts?.timeoutMs === undefined) {
await job.settled;
}
else {
await Promise.race([job.settled, afterMs(opts.timeoutMs)]);
}
}
return acknowledge(job);
}
/**
* Kill a running command: SIGTERM (or the signal you name), SIGKILL five
* seconds later if it is still there. Resolves with the settled status, so a
* caller never has to guess whether the output was banked yet.
*
* Killing an already-settled command is a no-op that returns its status —
* the outcome is not discarded.
*
* @throws when the task id is unknown to this host
*/
export async function killBackgroundCommand(host, taskId, signal = "SIGTERM") {
const job = jobFor(host, taskId);
if (isSettled(job)) {
return acknowledge(job);
}
if (!job.error) {
job.error = `The command was killed with ${signal} by the caller.`;
}
job.terminate?.("killed", signal);
await Promise.race([job.settled, afterMs(SIGKILL_GRACE_MS * 2)]);
return acknowledge(job);
}
/**
* Kill every unsettled command this host started. Host lifecycle only
* (`shutdown()`/`dispose()`): a disposed instance must not leave child
* processes running with nobody left to collect them.
*
* @returns how many commands were signalled
*/
export async function killAllBackgroundCommands(host) {
const targets = [...commands.values()].filter((job) => job.host === host && !isSettled(job));
for (const job of targets) {
if (!job.error) {
job.error = "The command was killed: its host instance was disposed.";
}
job.terminate?.("killed", "SIGTERM");
}
if (targets.length > 0) {
// Bounded: a child that ignores SIGTERM gets the SIGKILL follow-up from
// terminate(); nothing here waits past that grace.
await Promise.race([
Promise.all(targets.map((job) => job.settled)),
afterMs(SIGKILL_GRACE_MS * 2),
]);
logger.debug("[BackgroundCommands] Host disposal killed commands", {
killed: targets.length,
});
}
return targets.length;
}
/**
* Read one character window of a command's output, straight from its log file.
*
* Works while the command is still running — that is the monitor case — and
* after it settled. Character offsets, `totalSize` and `hasMore` match
* `retrieve_context` exactly, so paging code written for one works on the
* other.
*
* @throws when the task id is unknown to this host
*/
export async function readBackgroundCommandOutput(host, taskId, page) {
const job = jobFor(host, taskId);
const stream = page.stream === "stderr" ? "stderr" : "stdout";
const limit = Math.min(Math.max(1, page.limit ?? DEFAULT_OUTPUT_PAGE_CHARS), MAX_OUTPUT_PAGE_CHARS);
const offset = Math.max(0, page.offset ?? 0);
if (isSettled(job)) {
job.acknowledged = true;
}
let content = "";
try {
content = await readFile(job.streams[stream].path, "utf-8");
}
catch (error) {
logger.debug("[BackgroundCommands] Output log not readable yet", {
taskId,
stream,
error: errorMessage(error),
});
}
return {
taskId,
stream,
content: content.slice(offset, offset + limit),
offset,
limit,
totalSize: content.length,
hasMore: offset + limit < content.length,
};
}
// ── Model-facing tools ─────────────────────────────────────────────────────
const START_SCHEMA = z.object({
argv: z
.array(z.string())
.describe('The executable followed by one entry per argument: ["pnpm", "run", "lint"]. ' +
"There is NO shell — pipes, redirects and quoting do nothing here."),
cwd: z
.string()
.optional()
.describe("Directory to run in. Must be inside the permitted root; defaults to it."),
timeoutMs: z
.number()
.int()
.positive()
.optional()
.describe("Wall-clock budget in milliseconds before the command is killed. " +
"Capped at the host policy's budget."),
});
const STATUS_SCHEMA = z.object({
taskId: z.string().describe("Task id returned by run_command_bg."),
waitMs: z
.number()
.optional()
.describe("Wait up to this many milliseconds for the command to finish. Omit to " +
"read the status as it stands right now."),
});
const OUTPUT_SCHEMA = z.object({
taskId: z.string().describe("Task id returned by run_command_bg."),
stream: z
.enum(["stdout", "stderr"])
.optional()
.describe('Which stream to read. Default "stdout".'),
offset: z
.number()
.optional()
.describe("Character offset to start at. Default 0."),
limit: z
.number()
.optional()
.describe("Maximum characters to return. Default 50000, cap 200000."),
});
const KILL_SCHEMA = z.object({
taskId: z.string().describe("Task id returned by run_command_bg."),
});
function withCounts(host, status, sessionId) {
return { ...status, ...backgroundCommandCounts(host, sessionId) };
}
/**
* The four model-facing command tools, bound to `host`. Register them with
* `host.registerTool()` (see `NeuroLink.registerBackgroundCommandTools()`),
* never on the tool registry directly: only the "user-defined" category
* reaches the LLM's tool schema.
*/
export function createBackgroundCommandTools(host) {
return {
run_command_bg: {
name: "run_command_bg",
description: "Start a command in the BACKGROUND and get a taskId back immediately — the " +
"command keeps running while you do other work. Use it for checks whose output " +
"is evidence: builds, test suites, linters. The complete stdout and stderr are " +
"written to files and banked, so nothing is ever truncated away; read them with " +
"command_output. Only allowlisted executables may be run, and there is no shell.",
inputSchema: START_SCHEMA,
execute: async (params, executionContext) => {
const parsed = START_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal("run_command_bg expects { argv: string[], cwd?, timeoutMs? } with argv " +
'non-empty — e.g. { argv: ["pnpm", "run", "lint"] }. Call it again in that shape.');
}
const policy = hostPolicies.get(host);
if (!policy) {
return refusal(NO_POLICY_REFUSAL);
}
const sessionId = resolveChecklistSessionId(host, executionContext);
try {
// The model may narrow the budget, never widen it: the policy's
// default is the host's declared ceiling for model-started commands.
const budget = policy.defaultTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
const handle = await startBackgroundCommand(host, parsed.data.argv, {
cwd: parsed.data.cwd ?? policy.cwdRoot,
sessionId,
...(parsed.data.timeoutMs !== undefined && {
timeoutMs: Math.min(Math.max(1, parsed.data.timeoutMs), budget),
}),
});
const result = {
...handle,
...backgroundCommandCounts(host, sessionId),
};
return result;
}
catch (error) {
return refusal(errorMessage(error));
}
},
},
command_status: {
name: "command_status",
description: "Check a background command: its state, exit code, how many bytes each stream " +
"produced, and a bounded tail of the output. Pass waitMs to wait for it to " +
"finish instead of polling. Once it has finished, stdout and stderr carry " +
"artifact ids for the COMPLETE output — the tail is orientation, not evidence.",
inputSchema: STATUS_SCHEMA,
execute: async (params, executionContext) => {
const parsed = STATUS_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal("command_status expects { taskId, waitMs? }. Call it again with the taskId " +
"run_command_bg returned.");
}
const sessionId = resolveChecklistSessionId(host, executionContext);
try {
const status = parsed.data.waitMs === undefined
? getBackgroundCommandStatus(host, parsed.data.taskId)
: await awaitBackgroundCommand(host, parsed.data.taskId, {
timeoutMs: parsed.data.waitMs,
});
return withCounts(host, status, sessionId);
}
catch (error) {
return refusal(errorMessage(error));
}
},
},
command_output: {
name: "command_output",
description: "Read a window of a background command's output, by character offset. Works " +
"while the command is still running and after it finished. totalSize and " +
"hasMore tell you how much is left; page forward by advancing offset.",
inputSchema: OUTPUT_SCHEMA,
execute: async (params) => {
const parsed = OUTPUT_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal('command_output expects { taskId, stream?: "stdout" | "stderr", offset?, limit? }. ' +
"Call it again with the taskId run_command_bg returned.");
}
try {
return await readBackgroundCommandOutput(host, parsed.data.taskId, {
stream: parsed.data.stream ?? "stdout",
...(parsed.data.offset !== undefined && {
offset: parsed.data.offset,
}),
...(parsed.data.limit !== undefined && {
limit: parsed.data.limit,
}),
});
}
catch (error) {
return refusal(errorMessage(error));
}
},
},
command_kill: {
name: "command_kill",
description: "Stop a background command you no longer need. Whatever it printed before it " +
"stopped is still banked and still readable — killing a command discards the " +
"process, never its output.",
inputSchema: KILL_SCHEMA,
execute: async (params, executionContext) => {
const parsed = KILL_SCHEMA.safeParse(params ?? {});
if (!parsed.success) {
return refusal("command_kill expects { taskId }. Call it again with the taskId " +
"run_command_bg returned.");
}
const sessionId = resolveChecklistSessionId(host, executionContext);
try {
const status = await killBackgroundCommand(host, parsed.data.taskId);
return withCounts(host, status, sessionId);
}
catch (error) {
return refusal(errorMessage(error));
}
},
},
};
}