@juspay/neurolink 12.0.5 → 12.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/dist/agent/agentToolRegistrar.d.ts +30 -0
- package/dist/agent/agentToolRegistrar.js +72 -18
- package/dist/agent/backgroundCommands.d.ts +110 -0
- package/dist/agent/backgroundCommands.js +914 -0
- package/dist/agent/backgroundDelegation.d.ts +87 -0
- package/dist/agent/backgroundDelegation.js +753 -0
- package/dist/agent/gitTools.d.ts +43 -0
- package/dist/agent/gitTools.js +618 -0
- package/dist/agent/taskChecklist.d.ts +58 -0
- package/dist/agent/taskChecklist.js +322 -0
- package/dist/artifacts/artifactBanking.d.ts +57 -0
- package/dist/artifacts/artifactBanking.js +123 -0
- package/dist/artifacts/artifactStore.d.ts +36 -8
- package/dist/artifacts/artifactStore.js +164 -13
- package/dist/browser/neurolink.min.js +442 -414
- package/dist/cli/commands/setup.js +2 -1
- package/dist/constants/enums.d.ts +19 -0
- package/dist/constants/enums.js +20 -0
- package/dist/factories/providerDescriptors.js +16 -1
- package/dist/models/manifestRegistry.js +2 -0
- package/dist/models/manifests/cerebras.d.ts +9 -0
- package/dist/models/manifests/cerebras.js +19 -0
- package/dist/neurolink.d.ts +294 -3
- package/dist/neurolink.js +447 -4
- package/dist/providers/openaiCompatCatalog.d.ts +1 -1
- package/dist/providers/openaiCompatCatalog.js +34 -3
- package/dist/types/artifact.d.ts +54 -0
- package/dist/types/backgroundCommand.d.ts +174 -0
- package/dist/types/backgroundCommand.js +22 -0
- package/dist/types/delegation.d.ts +178 -0
- package/dist/types/delegation.js +18 -0
- package/dist/types/gitTools.d.ts +69 -0
- package/dist/types/gitTools.js +22 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +8 -0
- package/dist/types/pathSandbox.d.ts +23 -0
- package/dist/types/pathSandbox.js +12 -0
- package/dist/types/providers.d.ts +4 -0
- package/dist/types/tasks.d.ts +85 -0
- package/dist/types/tasks.js +14 -0
- package/dist/types/tools.d.ts +11 -0
- package/dist/utils/modelChoices.js +17 -1
- package/dist/utils/pathSandbox.d.ts +49 -0
- package/dist/utils/pathSandbox.js +127 -0
- package/dist/utils/providerConfig.d.ts +4 -0
- package/dist/utils/providerConfig.js +17 -0
- package/package.json +5 -1
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background commands (N4) — run a command detached, bank every byte it
|
|
3
|
+
* writes, monitor it while it runs.
|
|
4
|
+
*
|
|
5
|
+
* A reviewing agent has to run real commands — a build, a test suite, a linter
|
|
6
|
+
* whose output IS the evidence for a finding — and the naive shapes both fail.
|
|
7
|
+
* `bashTool` blocks the loop, hands the model a shell, and truncates its own
|
|
8
|
+
* output at 100 KB. A `child_process` call with a string command is a shell
|
|
9
|
+
* injection with extra steps.
|
|
10
|
+
*
|
|
11
|
+
* This module keeps three promises instead:
|
|
12
|
+
*
|
|
13
|
+
* - **Detached.** `startBackgroundCommand` returns a `taskId` immediately; the
|
|
14
|
+
* agent keeps working and asks about the command when it wants to.
|
|
15
|
+
* - **Nothing discarded.** Both streams are written straight to files as they
|
|
16
|
+
* arrive and the COMPLETE files are banked as artifacts (N3) when the
|
|
17
|
+
* command settles. The conversation gets a bounded tail plus a read-back
|
|
18
|
+
* call. The single bound is `maxOutputBytes`, and reaching it is a state
|
|
19
|
+
* (`output-limit`) the caller can see, not a silent cut.
|
|
20
|
+
* - **Hardened by contract.** argv arrays with `shell: false`, an exact-match
|
|
21
|
+
* executable allowlist, a cwd that must resolve through symlinks inside a
|
|
22
|
+
* declared root, and a timeout that escalates SIGTERM → SIGKILL.
|
|
23
|
+
*
|
|
24
|
+
* Completion reaches the model the same way a delegate's does (N2.3): the
|
|
25
|
+
* `running` / `finished` counters ride on every command tool result and — via
|
|
26
|
+
* the checklist — on every `tasks_list`. The core generate loop is untouched.
|
|
27
|
+
*
|
|
28
|
+
* @module agent/backgroundCommands
|
|
29
|
+
*/
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { createWriteStream } from "node:fs";
|
|
33
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
import { logger } from "../utils/logger.js";
|
|
38
|
+
import { resolveWithinRoot } from "../utils/pathSandbox.js";
|
|
39
|
+
import { resolveChecklistSessionId, setChecklistCommandCountsSource, } from "./taskChecklist.js";
|
|
40
|
+
/**
|
|
41
|
+
* Every command this process started, keyed by taskId.
|
|
42
|
+
*
|
|
43
|
+
* Module-level for the same reason the checklist and the delegation registry
|
|
44
|
+
* are: a command outlives the tool call that started it, and compaction — which
|
|
45
|
+
* only rewrites messages — cannot touch a module map. Settled jobs are NOT
|
|
46
|
+
* evicted: their log files and banked artifacts are the run's evidence, and a
|
|
47
|
+
* status call that answers "unknown taskId" for a command that ran is exactly
|
|
48
|
+
* the information loss this primitive exists to prevent.
|
|
49
|
+
*/
|
|
50
|
+
const commands = new Map();
|
|
51
|
+
const hostPolicies = new WeakMap();
|
|
52
|
+
/** Wall-clock budget when neither the caller nor the policy names one. */
|
|
53
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
54
|
+
/** Per-stream byte cap when neither the caller nor the policy names one. */
|
|
55
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 10_485_760;
|
|
56
|
+
/** How long a SIGTERMed process has to unwind before SIGKILL. */
|
|
57
|
+
const SIGKILL_GRACE_MS = 5_000;
|
|
58
|
+
/** Hard bound on `tailPreview`, whatever the streams hold. */
|
|
59
|
+
const TAIL_PREVIEW_CHARS = 2_000;
|
|
60
|
+
/**
|
|
61
|
+
* Raw bytes kept per stream for the tail. Four bytes per character is the
|
|
62
|
+
* UTF-8 worst case, so this can always produce a full-length preview.
|
|
63
|
+
*/
|
|
64
|
+
const TAIL_BUFFER_BYTES = TAIL_PREVIEW_CHARS * 4;
|
|
65
|
+
/** Preview cut into the conversation from each banked stream. */
|
|
66
|
+
const OUTPUT_BANK_PREVIEW_CHARS = 600;
|
|
67
|
+
/** Characters returned by one `command_output` page when none is asked for. */
|
|
68
|
+
const DEFAULT_OUTPUT_PAGE_CHARS = 50_000;
|
|
69
|
+
/** Ceiling on one page, however much the caller asks for. */
|
|
70
|
+
const MAX_OUTPUT_PAGE_CHARS = 200_000;
|
|
71
|
+
/** Longest label derived from an argv. */
|
|
72
|
+
const LABEL_MAX_CHARS = 60;
|
|
73
|
+
/** Directory under the OS temp dir that holds every command's logs. */
|
|
74
|
+
const COMMAND_LOG_DIR = "neurolink-commands";
|
|
75
|
+
/**
|
|
76
|
+
* Characters that only mean something to a shell.
|
|
77
|
+
*
|
|
78
|
+
* argv[0] is executed directly — there is no shell to interpret them — so
|
|
79
|
+
* their presence means the caller believed it was writing a shell command
|
|
80
|
+
* line. Refusing loudly is far kinder than spawning an executable literally
|
|
81
|
+
* named `sh -c rm -rf /`, which is what would otherwise happen.
|
|
82
|
+
*/
|
|
83
|
+
const SHELL_METACHARACTERS = /[;&|<>$`\n\r]/;
|
|
84
|
+
const STREAM_NAMES = [
|
|
85
|
+
"stdout",
|
|
86
|
+
"stderr",
|
|
87
|
+
];
|
|
88
|
+
let commandCounter = 0;
|
|
89
|
+
let checklistCountsInstalled = false;
|
|
90
|
+
// ── Small helpers ──────────────────────────────────────────────────────────
|
|
91
|
+
function errorMessage(error) {
|
|
92
|
+
return error instanceof Error ? error.message : String(error);
|
|
93
|
+
}
|
|
94
|
+
/** Matches `agentToolRegistrar`'s convention: the recovery step is IN the text. */
|
|
95
|
+
function refusal(message) {
|
|
96
|
+
return { isError: true, error: message };
|
|
97
|
+
}
|
|
98
|
+
function bounded(text, maxChars) {
|
|
99
|
+
return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
|
|
100
|
+
}
|
|
101
|
+
function labelFor(argv, explicit) {
|
|
102
|
+
const chosen = explicit?.trim() || argv.slice(0, 2).join(" ") || argv[0] || "command";
|
|
103
|
+
return bounded(chosen, LABEL_MAX_CHARS);
|
|
104
|
+
}
|
|
105
|
+
function emptyStream(path) {
|
|
106
|
+
return {
|
|
107
|
+
path,
|
|
108
|
+
bytes: 0,
|
|
109
|
+
tailBytes: Buffer.alloc(0),
|
|
110
|
+
limitReached: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Decode a byte tail. The window starts wherever the stream happened to be,
|
|
115
|
+
* so it may open mid-character; the replacement character that produces is
|
|
116
|
+
* dropped rather than shown.
|
|
117
|
+
*/
|
|
118
|
+
function decodeTail(bytes) {
|
|
119
|
+
const text = bytes.toString("utf-8");
|
|
120
|
+
const trimmed = text.startsWith("\uFFFD") ? text.slice(1) : text;
|
|
121
|
+
return trimmed.slice(-TAIL_PREVIEW_CHARS);
|
|
122
|
+
}
|
|
123
|
+
// ── Policy ─────────────────────────────────────────────────────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Declare what this host may execute. There is no default: until this is
|
|
126
|
+
* called, every start is refused, because "run whatever the model asks" is not
|
|
127
|
+
* a defensible default for a primitive that spawns processes.
|
|
128
|
+
*/
|
|
129
|
+
export function setBackgroundCommandPolicy(host, policy) {
|
|
130
|
+
hostPolicies.set(host, policy);
|
|
131
|
+
installChecklistCounts();
|
|
132
|
+
logger.debug("[BackgroundCommands] Policy set", {
|
|
133
|
+
allowedExecutables: policy.allowedExecutables.length,
|
|
134
|
+
cwdRoot: policy.cwdRoot,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** The policy in force for a host, or undefined when none was declared. */
|
|
138
|
+
export function getBackgroundCommandPolicy(host) {
|
|
139
|
+
return hostPolicies.get(host);
|
|
140
|
+
}
|
|
141
|
+
const NO_POLICY_REFUSAL = "No background-command policy is set on this instance, so nothing may be executed. " +
|
|
142
|
+
"The host must call setBackgroundCommandPolicy({ allowedExecutables, cwdRoot }) — " +
|
|
143
|
+
"or registerBackgroundCommandTools(policy) — before any command can start. Do the " +
|
|
144
|
+
"work with your other tools instead.";
|
|
145
|
+
// ── Counting ───────────────────────────────────────────────────────────────
|
|
146
|
+
function isSettled(job) {
|
|
147
|
+
return job.settledAt !== undefined;
|
|
148
|
+
}
|
|
149
|
+
function tally(candidates) {
|
|
150
|
+
let running = 0;
|
|
151
|
+
let finished = 0;
|
|
152
|
+
for (const job of candidates) {
|
|
153
|
+
if (!isSettled(job)) {
|
|
154
|
+
running += 1;
|
|
155
|
+
}
|
|
156
|
+
else if (!job.acknowledged) {
|
|
157
|
+
finished += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { running, finished };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Hand out a job's status and record that someone has now seen it.
|
|
164
|
+
*
|
|
165
|
+
* A settled command stays in the registry forever — its logs and artifacts are
|
|
166
|
+
* the run's evidence — so "finished" has to mean "finished and unread" or the
|
|
167
|
+
* counter only ever climbs and stops meaning anything. Nothing is dropped when
|
|
168
|
+
* it clears; only the flag moves.
|
|
169
|
+
*/
|
|
170
|
+
function acknowledge(job) {
|
|
171
|
+
if (isSettled(job)) {
|
|
172
|
+
job.acknowledged = true;
|
|
173
|
+
}
|
|
174
|
+
return statusOf(job);
|
|
175
|
+
}
|
|
176
|
+
/** Counts across every host for one session — what the checklist reads. */
|
|
177
|
+
function countsForSession(sessionId) {
|
|
178
|
+
return tally([...commands.values()].filter((job) => job.sessionId === sessionId));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Commands for a host's session: `running` have not settled, `finished` have.
|
|
182
|
+
* Carried on every command tool result and on every `ChecklistToolResult`, so
|
|
183
|
+
* the model learns a build finished without polling for it.
|
|
184
|
+
*/
|
|
185
|
+
export function backgroundCommandCounts(host, sessionId) {
|
|
186
|
+
const session = sessionId ?? resolveChecklistSessionId(host);
|
|
187
|
+
return tally([...commands.values()].filter((job) => job.host === host && job.sessionId === session));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Feed the task checklist's command counters, once per process — the N2.3
|
|
191
|
+
* notification channel, reused rather than rebuilt.
|
|
192
|
+
*/
|
|
193
|
+
function installChecklistCounts() {
|
|
194
|
+
if (checklistCountsInstalled) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setChecklistCommandCountsSource(countsForSession);
|
|
198
|
+
checklistCountsInstalled = true;
|
|
199
|
+
}
|
|
200
|
+
// ── Validation ─────────────────────────────────────────────────────────────
|
|
201
|
+
/**
|
|
202
|
+
* Everything that must hold before a process is created. Returns the refusal
|
|
203
|
+
* reason, or undefined when the start may proceed.
|
|
204
|
+
*
|
|
205
|
+
* Each check names its own recovery step: a refusal the model cannot act on
|
|
206
|
+
* just becomes a retry of the same call.
|
|
207
|
+
*/
|
|
208
|
+
function validateArgv(argv, policy) {
|
|
209
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
210
|
+
return ("A command needs a non-empty argv array: the executable first, then one array " +
|
|
211
|
+
'entry per argument — ["pnpm", "run", "lint"], never a single command string.');
|
|
212
|
+
}
|
|
213
|
+
if (argv.some((part) => typeof part !== "string")) {
|
|
214
|
+
return "Every argv entry must be a string. Pass each argument as its own entry.";
|
|
215
|
+
}
|
|
216
|
+
if (argv.some((part) => part.includes("\0"))) {
|
|
217
|
+
return "argv entries must not contain NUL bytes. Remove it and retry.";
|
|
218
|
+
}
|
|
219
|
+
const executable = argv[0];
|
|
220
|
+
if (SHELL_METACHARACTERS.test(executable) || /\s/.test(executable)) {
|
|
221
|
+
return (`"${bounded(executable, 80)}" is not an executable name. Commands run with NO shell, ` +
|
|
222
|
+
"so pipes, redirects, semicolons and quoting do nothing — put the executable in " +
|
|
223
|
+
"argv[0] and every argument in its own entry. If you need a pipeline, run the " +
|
|
224
|
+
"steps as separate commands.");
|
|
225
|
+
}
|
|
226
|
+
if (!policy.allowedExecutables.includes(executable)) {
|
|
227
|
+
const allowed = policy.allowedExecutables.join(", ") || "(none)";
|
|
228
|
+
return (`Executable "${executable}" is not allowed here. Permitted executables are: ${allowed}. ` +
|
|
229
|
+
"Use one of those, or do the work with your other tools.");
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
// ── Job bookkeeping ────────────────────────────────────────────────────────
|
|
234
|
+
function jobFor(host, taskId) {
|
|
235
|
+
const job = commands.get(taskId);
|
|
236
|
+
if (!job || job.host !== host) {
|
|
237
|
+
const known = [...commands.values()]
|
|
238
|
+
.filter((candidate) => candidate.host === host)
|
|
239
|
+
.map((candidate) => candidate.taskId);
|
|
240
|
+
throw new Error(known.length > 0
|
|
241
|
+
? `No background command "${taskId}". Known task ids are ${known.join(", ")} — ` +
|
|
242
|
+
"use one of those."
|
|
243
|
+
: `No background command "${taskId}": this instance has not started any. ` +
|
|
244
|
+
"Start one with run_command_bg first.");
|
|
245
|
+
}
|
|
246
|
+
return job;
|
|
247
|
+
}
|
|
248
|
+
function renderTail(job) {
|
|
249
|
+
const out = decodeTail(job.streams.stdout.tailBytes);
|
|
250
|
+
const err = decodeTail(job.streams.stderr.tailBytes);
|
|
251
|
+
if (!out && !err) {
|
|
252
|
+
return isSettled(job) ? "(the command wrote no output)" : "(no output yet)";
|
|
253
|
+
}
|
|
254
|
+
const budget = out && err ? Math.floor(TAIL_PREVIEW_CHARS / 2) : TAIL_PREVIEW_CHARS;
|
|
255
|
+
const parts = [];
|
|
256
|
+
if (out) {
|
|
257
|
+
parts.push(`[stdout tail]\n${out.slice(-budget)}`);
|
|
258
|
+
}
|
|
259
|
+
if (err) {
|
|
260
|
+
parts.push(`[stderr tail]\n${err.slice(-budget)}`);
|
|
261
|
+
}
|
|
262
|
+
return parts.join("\n");
|
|
263
|
+
}
|
|
264
|
+
/** The public view of a job, built fresh on every read so it is never stale. */
|
|
265
|
+
function statusOf(job) {
|
|
266
|
+
return {
|
|
267
|
+
taskId: job.taskId,
|
|
268
|
+
label: job.label,
|
|
269
|
+
state: job.state,
|
|
270
|
+
...(job.exitCode !== undefined && { exitCode: job.exitCode }),
|
|
271
|
+
...(job.signal && { signal: job.signal }),
|
|
272
|
+
durationMs: (job.settledAt ?? Date.now()) - job.startedAt,
|
|
273
|
+
stdoutBytes: job.streams.stdout.bytes,
|
|
274
|
+
stderrBytes: job.streams.stderr.bytes,
|
|
275
|
+
...(job.streams.stdout.banked && { stdout: job.streams.stdout.banked }),
|
|
276
|
+
...(job.streams.stderr.banked && { stderr: job.streams.stderr.banked }),
|
|
277
|
+
tailPreview: renderTail(job),
|
|
278
|
+
...(job.error && { error: job.error }),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Bank one stream's log file and hand back the pointer.
|
|
283
|
+
*
|
|
284
|
+
* A banking failure is reported in the reference rather than thrown: losing
|
|
285
|
+
* the artifact must not also lose the command's outcome, and the caller is
|
|
286
|
+
* told in so many words that the read-back is unavailable and why — the log
|
|
287
|
+
* file itself is still on disk at the path named in the hint.
|
|
288
|
+
*/
|
|
289
|
+
async function bankStream(job, name) {
|
|
290
|
+
const stream = job.streams[name];
|
|
291
|
+
const label = `${job.label} [${name}]`;
|
|
292
|
+
let content = "";
|
|
293
|
+
try {
|
|
294
|
+
content = await readFile(stream.path, "utf-8");
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
logger.warn("[BackgroundCommands] Reading the stream log failed", {
|
|
298
|
+
taskId: job.taskId,
|
|
299
|
+
stream: name,
|
|
300
|
+
error: errorMessage(error),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
return await job.host.bankArtifact(content, {
|
|
305
|
+
kind: "command-output",
|
|
306
|
+
label,
|
|
307
|
+
sessionId: job.sessionId,
|
|
308
|
+
previewChars: OUTPUT_BANK_PREVIEW_CHARS,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
const message = errorMessage(error);
|
|
313
|
+
logger.warn("[BackgroundCommands] Banking the command output failed", {
|
|
314
|
+
taskId: job.taskId,
|
|
315
|
+
stream: name,
|
|
316
|
+
error: message,
|
|
317
|
+
});
|
|
318
|
+
return {
|
|
319
|
+
artifactId: "",
|
|
320
|
+
label,
|
|
321
|
+
kind: "command-output",
|
|
322
|
+
sizeBytes: stream.bytes,
|
|
323
|
+
preview: bounded(content, OUTPUT_BANK_PREVIEW_CHARS),
|
|
324
|
+
readBackHint: `The ${name} artifact could NOT be created (${message}), so retrieve_context has ` +
|
|
325
|
+
`nothing to read. The complete log is still on disk at ${stream.path}.`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// ── Starting a command ─────────────────────────────────────────────────────
|
|
330
|
+
function resolveState(job, reason) {
|
|
331
|
+
if (reason === "timeout") {
|
|
332
|
+
return "timeout";
|
|
333
|
+
}
|
|
334
|
+
if (reason === "output-limit") {
|
|
335
|
+
return "output-limit";
|
|
336
|
+
}
|
|
337
|
+
// sink-error included: if the child exits on its own before the SIGTERM
|
|
338
|
+
// lands, `job.signal` stays unset and the job would settle as a clean
|
|
339
|
+
// `exited` — with a banked log the failed sink silently truncated.
|
|
340
|
+
if (reason === "killed" || reason === "aborted" || reason === "sink-error") {
|
|
341
|
+
return "killed";
|
|
342
|
+
}
|
|
343
|
+
// Nobody here asked for it: an external signal still ended the process.
|
|
344
|
+
return job.signal ? "killed" : "exited";
|
|
345
|
+
}
|
|
346
|
+
function endWriteStream(stream) {
|
|
347
|
+
return new Promise((resolve) => {
|
|
348
|
+
// An errored sink auto-destroys, and end() on a destroyed stream never
|
|
349
|
+
// calls back — finish() must not hang on it.
|
|
350
|
+
if (stream.destroyed) {
|
|
351
|
+
resolve();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
stream.end(() => resolve());
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Pipe one stream to its log file while counting bytes, keeping a UTF-8-safe
|
|
359
|
+
* rolling tail, and enforcing the byte cap.
|
|
360
|
+
*
|
|
361
|
+
* At the cap, everything up to it stays on disk in full and the command is
|
|
362
|
+
* killed with state `output-limit` — a loud stop, never a silent truncation
|
|
363
|
+
* that leaves the caller believing it read the whole thing.
|
|
364
|
+
*/
|
|
365
|
+
function attachStream(source, state, sink, maxOutputBytes, onLimit) {
|
|
366
|
+
source.on("data", (chunk) => {
|
|
367
|
+
if (state.limitReached) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const room = maxOutputBytes - state.bytes;
|
|
371
|
+
const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
|
|
372
|
+
if (slice.length > 0) {
|
|
373
|
+
state.bytes += slice.length;
|
|
374
|
+
state.tailBytes = Buffer.concat([state.tailBytes, slice]).subarray(-TAIL_BUFFER_BYTES);
|
|
375
|
+
if (!sink.write(slice)) {
|
|
376
|
+
source.pause();
|
|
377
|
+
sink.once("drain", () => source.resume());
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Strict: a chunk that exactly fills the remaining room is complete
|
|
381
|
+
// output, not overflow. When room hits 0, any later non-empty chunk is
|
|
382
|
+
// still `> room`, so real overflow is never missed.
|
|
383
|
+
if (chunk.length > room) {
|
|
384
|
+
state.limitReached = true;
|
|
385
|
+
onLimit();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
source.on("error", (error) => {
|
|
389
|
+
logger.warn("[BackgroundCommands] Output stream error", {
|
|
390
|
+
error: errorMessage(error),
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Start a command with an explicit policy, bypassing the host's own.
|
|
396
|
+
*
|
|
397
|
+
* The git toolset uses this so registering read-only git tools never widens
|
|
398
|
+
* what `run_command_bg` may execute, and never requires the host to declare a
|
|
399
|
+
* general command policy at all.
|
|
400
|
+
*
|
|
401
|
+
* @internal
|
|
402
|
+
*/
|
|
403
|
+
export async function startCommandWithPolicy(host, argv, options, policy) {
|
|
404
|
+
const invalid = validateArgv(argv, policy);
|
|
405
|
+
if (invalid) {
|
|
406
|
+
throw new Error(invalid);
|
|
407
|
+
}
|
|
408
|
+
const sandboxed = resolveWithinRoot(options.cwd, policy.cwdRoot);
|
|
409
|
+
if (sandboxed.error !== undefined) {
|
|
410
|
+
throw new Error(sandboxed.error);
|
|
411
|
+
}
|
|
412
|
+
const cwd = sandboxed.path;
|
|
413
|
+
const vetoed = policy.allowlist?.(argv, cwd);
|
|
414
|
+
if (typeof vetoed === "string") {
|
|
415
|
+
throw new Error(vetoed);
|
|
416
|
+
}
|
|
417
|
+
installChecklistCounts();
|
|
418
|
+
commandCounter += 1;
|
|
419
|
+
const taskId = `c${commandCounter}${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
420
|
+
const dir = join(tmpdir(), COMMAND_LOG_DIR, taskId);
|
|
421
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
422
|
+
const startedAt = Date.now();
|
|
423
|
+
const timeoutMs = options.timeoutMs ?? policy.defaultTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
424
|
+
const maxOutputBytes = options.maxOutputBytes ?? policy.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
425
|
+
let settleJob = () => undefined;
|
|
426
|
+
const settled = new Promise((resolvePromise) => {
|
|
427
|
+
settleJob = resolvePromise;
|
|
428
|
+
});
|
|
429
|
+
const job = {
|
|
430
|
+
taskId,
|
|
431
|
+
host,
|
|
432
|
+
sessionId: options.sessionId ?? resolveChecklistSessionId(host),
|
|
433
|
+
label: labelFor(argv, options.label),
|
|
434
|
+
argv: [...argv],
|
|
435
|
+
cwd,
|
|
436
|
+
state: "queued",
|
|
437
|
+
startedAt,
|
|
438
|
+
acknowledged: false,
|
|
439
|
+
streams: {
|
|
440
|
+
stdout: emptyStream(join(dir, "stdout.log")),
|
|
441
|
+
stderr: emptyStream(join(dir, "stderr.log")),
|
|
442
|
+
},
|
|
443
|
+
maxOutputBytes,
|
|
444
|
+
timeoutMs,
|
|
445
|
+
settled,
|
|
446
|
+
};
|
|
447
|
+
commands.set(taskId, job);
|
|
448
|
+
runCommand(job, options, settleJob);
|
|
449
|
+
logger.debug("[BackgroundCommands] Command started", {
|
|
450
|
+
taskId,
|
|
451
|
+
label: job.label,
|
|
452
|
+
cwd,
|
|
453
|
+
timeoutMs,
|
|
454
|
+
});
|
|
455
|
+
return { taskId, argv: [...argv], startedAt };
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Spawn the child and wire up everything that can end it. Never throws: a
|
|
459
|
+
* failure to spawn settles the job with the reason, because a command that
|
|
460
|
+
* vanishes is a command the agent waits on forever.
|
|
461
|
+
*/
|
|
462
|
+
function runCommand(job, options, settleJob) {
|
|
463
|
+
const outSink = createWriteStream(job.streams.stdout.path, { mode: 0o600 });
|
|
464
|
+
const errSink = createWriteStream(job.streams.stderr.path, { mode: 0o600 });
|
|
465
|
+
// A sink failure (ENOSPC, EACCES, a vanished parent directory) is emitted as
|
|
466
|
+
// an asynchronous 'error' event; with no listener that is an uncaught
|
|
467
|
+
// exception and a process crash. It settles the job instead: the command is
|
|
468
|
+
// killed and the failure is named on `job.error`.
|
|
469
|
+
const onSinkError = (name) => (error) => {
|
|
470
|
+
job.error ??= `The ${name} sink failed: ${errorMessage(error)}`;
|
|
471
|
+
terminate("sink-error");
|
|
472
|
+
};
|
|
473
|
+
outSink.on("error", onSinkError("stdout"));
|
|
474
|
+
errSink.on("error", onSinkError("stderr"));
|
|
475
|
+
// Declared before the spawn attempt: a spawn that throws settles the job
|
|
476
|
+
// through the same path as one that runs, and that path reads these.
|
|
477
|
+
let finished = false;
|
|
478
|
+
let terminationReason;
|
|
479
|
+
// Grouped so both are reachable from the settle path no matter which of the
|
|
480
|
+
// three ways of ending a command got there first.
|
|
481
|
+
const timers = {};
|
|
482
|
+
let child;
|
|
483
|
+
try {
|
|
484
|
+
child = spawn(job.argv[0], job.argv.slice(1), {
|
|
485
|
+
cwd: job.cwd,
|
|
486
|
+
shell: false,
|
|
487
|
+
windowsHide: true,
|
|
488
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
489
|
+
...(options.env && { env: options.env }),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
job.error = `The command could not be started: ${errorMessage(error)}`;
|
|
494
|
+
void finish(undefined);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
function terminate(reason, signal = "SIGTERM") {
|
|
498
|
+
if (terminationReason || finished) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
terminationReason = reason;
|
|
502
|
+
try {
|
|
503
|
+
child.kill(signal);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
logger.warn("[BackgroundCommands] Signalling the command failed", {
|
|
507
|
+
taskId: job.taskId,
|
|
508
|
+
error: errorMessage(error),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
// A process that ignores SIGTERM must not be able to outlive its budget.
|
|
512
|
+
timers.kill = setTimeout(() => {
|
|
513
|
+
try {
|
|
514
|
+
child.kill("SIGKILL");
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
/* already gone */
|
|
518
|
+
}
|
|
519
|
+
}, SIGKILL_GRACE_MS);
|
|
520
|
+
timers.kill.unref?.();
|
|
521
|
+
}
|
|
522
|
+
job.terminate = terminate;
|
|
523
|
+
async function finish(reason) {
|
|
524
|
+
if (finished) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
finished = true;
|
|
528
|
+
clearTimeout(timers.timeout);
|
|
529
|
+
clearTimeout(timers.kill);
|
|
530
|
+
job.detachParent?.();
|
|
531
|
+
await Promise.all([endWriteStream(outSink), endWriteStream(errSink)]);
|
|
532
|
+
job.settledAt = Date.now();
|
|
533
|
+
job.state = resolveState(job, reason);
|
|
534
|
+
for (const name of STREAM_NAMES) {
|
|
535
|
+
job.streams[name].banked = await bankStream(job, name);
|
|
536
|
+
}
|
|
537
|
+
if (!job.error && job.streams.stdout.limitReached) {
|
|
538
|
+
job.error =
|
|
539
|
+
`stdout reached the ${job.maxOutputBytes}-byte cap and the command was killed. ` +
|
|
540
|
+
"Everything written up to the cap is banked in full.";
|
|
541
|
+
}
|
|
542
|
+
else if (!job.error && job.streams.stderr.limitReached) {
|
|
543
|
+
job.error =
|
|
544
|
+
`stderr reached the ${job.maxOutputBytes}-byte cap and the command was killed. ` +
|
|
545
|
+
"Everything written up to the cap is banked in full.";
|
|
546
|
+
}
|
|
547
|
+
const status = statusOf(job);
|
|
548
|
+
logger.debug("[BackgroundCommands] Command settled", {
|
|
549
|
+
taskId: job.taskId,
|
|
550
|
+
state: status.state,
|
|
551
|
+
exitCode: status.exitCode,
|
|
552
|
+
durationMs: status.durationMs,
|
|
553
|
+
});
|
|
554
|
+
settleJob(status);
|
|
555
|
+
}
|
|
556
|
+
if (child.stdout) {
|
|
557
|
+
attachStream(child.stdout, job.streams.stdout, outSink, job.maxOutputBytes, () => terminate("output-limit"));
|
|
558
|
+
}
|
|
559
|
+
if (child.stderr) {
|
|
560
|
+
attachStream(child.stderr, job.streams.stderr, errSink, job.maxOutputBytes, () => terminate("output-limit"));
|
|
561
|
+
}
|
|
562
|
+
child.on("spawn", () => {
|
|
563
|
+
if (job.state === "queued") {
|
|
564
|
+
job.state = "running";
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
child.on("error", (error) => {
|
|
568
|
+
job.error = `The command could not be started: ${error.message}`;
|
|
569
|
+
void finish(terminationReason);
|
|
570
|
+
});
|
|
571
|
+
child.on("close", (code, signal) => {
|
|
572
|
+
if (code !== null) {
|
|
573
|
+
job.exitCode = code;
|
|
574
|
+
}
|
|
575
|
+
if (signal) {
|
|
576
|
+
job.signal = signal;
|
|
577
|
+
}
|
|
578
|
+
void finish(terminationReason);
|
|
579
|
+
});
|
|
580
|
+
timers.timeout = setTimeout(() => {
|
|
581
|
+
job.error =
|
|
582
|
+
`The command exceeded its ${job.timeoutMs}ms budget and was killed. Output up to ` +
|
|
583
|
+
"that point is banked in full.";
|
|
584
|
+
terminate("timeout");
|
|
585
|
+
}, job.timeoutMs);
|
|
586
|
+
timers.timeout.unref?.();
|
|
587
|
+
const parentSignal = options.abortSignal;
|
|
588
|
+
if (parentSignal) {
|
|
589
|
+
if (parentSignal.aborted) {
|
|
590
|
+
terminate("aborted");
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
const onAbort = () => terminate("aborted");
|
|
594
|
+
parentSignal.addEventListener("abort", onAbort, { once: true });
|
|
595
|
+
job.detachParent = () => parentSignal.removeEventListener("abort", onAbort);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Start a command in the background and get its task id immediately.
|
|
601
|
+
*
|
|
602
|
+
* @throws when no policy is set, argv is malformed, the executable is not
|
|
603
|
+
* allowlisted, the policy vetoes the command, or the cwd escapes the
|
|
604
|
+
* sandbox root. Every message names its own recovery step.
|
|
605
|
+
*/
|
|
606
|
+
export async function startBackgroundCommand(host, argv, options) {
|
|
607
|
+
const policy = hostPolicies.get(host);
|
|
608
|
+
if (!policy) {
|
|
609
|
+
throw new Error(NO_POLICY_REFUSAL);
|
|
610
|
+
}
|
|
611
|
+
return startCommandWithPolicy(host, argv, options, policy);
|
|
612
|
+
}
|
|
613
|
+
// ── Monitoring ─────────────────────────────────────────────────────────────
|
|
614
|
+
/**
|
|
615
|
+
* Everything known about one command right now — synchronous, because the
|
|
616
|
+
* job state is live and a monitor that has to be awaited is a monitor nobody
|
|
617
|
+
* calls mid-loop.
|
|
618
|
+
*
|
|
619
|
+
* @throws when the task id is unknown to this host
|
|
620
|
+
*/
|
|
621
|
+
export function getBackgroundCommandStatus(host, taskId) {
|
|
622
|
+
return acknowledge(jobFor(host, taskId));
|
|
623
|
+
}
|
|
624
|
+
function afterMs(ms) {
|
|
625
|
+
return new Promise((resolve) => {
|
|
626
|
+
const timer = setTimeout(resolve, Math.max(0, ms));
|
|
627
|
+
timer.unref?.();
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Wait for a command to settle.
|
|
632
|
+
*
|
|
633
|
+
* `timeoutMs` bounds the WAIT, not the command: when it elapses the current
|
|
634
|
+
* (still running) status is returned rather than throwing, so a caller can
|
|
635
|
+
* poll in bounded steps without ever losing the job.
|
|
636
|
+
*
|
|
637
|
+
* @throws when the task id is unknown to this host
|
|
638
|
+
*/
|
|
639
|
+
export async function awaitBackgroundCommand(host, taskId, opts) {
|
|
640
|
+
const job = jobFor(host, taskId);
|
|
641
|
+
if (!isSettled(job)) {
|
|
642
|
+
if (opts?.timeoutMs === undefined) {
|
|
643
|
+
await job.settled;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
await Promise.race([job.settled, afterMs(opts.timeoutMs)]);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return acknowledge(job);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Kill a running command: SIGTERM (or the signal you name), SIGKILL five
|
|
653
|
+
* seconds later if it is still there. Resolves with the settled status, so a
|
|
654
|
+
* caller never has to guess whether the output was banked yet.
|
|
655
|
+
*
|
|
656
|
+
* Killing an already-settled command is a no-op that returns its status —
|
|
657
|
+
* the outcome is not discarded.
|
|
658
|
+
*
|
|
659
|
+
* @throws when the task id is unknown to this host
|
|
660
|
+
*/
|
|
661
|
+
export async function killBackgroundCommand(host, taskId, signal = "SIGTERM") {
|
|
662
|
+
const job = jobFor(host, taskId);
|
|
663
|
+
if (isSettled(job)) {
|
|
664
|
+
return acknowledge(job);
|
|
665
|
+
}
|
|
666
|
+
if (!job.error) {
|
|
667
|
+
job.error = `The command was killed with ${signal} by the caller.`;
|
|
668
|
+
}
|
|
669
|
+
job.terminate?.("killed", signal);
|
|
670
|
+
await Promise.race([job.settled, afterMs(SIGKILL_GRACE_MS * 2)]);
|
|
671
|
+
return acknowledge(job);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Kill every unsettled command this host started. Host lifecycle only
|
|
675
|
+
* (`shutdown()`/`dispose()`): a disposed instance must not leave child
|
|
676
|
+
* processes running with nobody left to collect them.
|
|
677
|
+
*
|
|
678
|
+
* @returns how many commands were signalled
|
|
679
|
+
*/
|
|
680
|
+
export async function killAllBackgroundCommands(host) {
|
|
681
|
+
const targets = [...commands.values()].filter((job) => job.host === host && !isSettled(job));
|
|
682
|
+
for (const job of targets) {
|
|
683
|
+
if (!job.error) {
|
|
684
|
+
job.error = "The command was killed: its host instance was disposed.";
|
|
685
|
+
}
|
|
686
|
+
job.terminate?.("killed", "SIGTERM");
|
|
687
|
+
}
|
|
688
|
+
if (targets.length > 0) {
|
|
689
|
+
// Bounded: a child that ignores SIGTERM gets the SIGKILL follow-up from
|
|
690
|
+
// terminate(); nothing here waits past that grace.
|
|
691
|
+
await Promise.race([
|
|
692
|
+
Promise.all(targets.map((job) => job.settled)),
|
|
693
|
+
afterMs(SIGKILL_GRACE_MS * 2),
|
|
694
|
+
]);
|
|
695
|
+
logger.debug("[BackgroundCommands] Host disposal killed commands", {
|
|
696
|
+
killed: targets.length,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
return targets.length;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Read one character window of a command's output, straight from its log file.
|
|
703
|
+
*
|
|
704
|
+
* Works while the command is still running — that is the monitor case — and
|
|
705
|
+
* after it settled. Character offsets, `totalSize` and `hasMore` match
|
|
706
|
+
* `retrieve_context` exactly, so paging code written for one works on the
|
|
707
|
+
* other.
|
|
708
|
+
*
|
|
709
|
+
* @throws when the task id is unknown to this host
|
|
710
|
+
*/
|
|
711
|
+
export async function readBackgroundCommandOutput(host, taskId, page) {
|
|
712
|
+
const job = jobFor(host, taskId);
|
|
713
|
+
const stream = page.stream === "stderr" ? "stderr" : "stdout";
|
|
714
|
+
const limit = Math.min(Math.max(1, page.limit ?? DEFAULT_OUTPUT_PAGE_CHARS), MAX_OUTPUT_PAGE_CHARS);
|
|
715
|
+
const offset = Math.max(0, page.offset ?? 0);
|
|
716
|
+
if (isSettled(job)) {
|
|
717
|
+
job.acknowledged = true;
|
|
718
|
+
}
|
|
719
|
+
let content = "";
|
|
720
|
+
try {
|
|
721
|
+
content = await readFile(job.streams[stream].path, "utf-8");
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
logger.debug("[BackgroundCommands] Output log not readable yet", {
|
|
725
|
+
taskId,
|
|
726
|
+
stream,
|
|
727
|
+
error: errorMessage(error),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
taskId,
|
|
732
|
+
stream,
|
|
733
|
+
content: content.slice(offset, offset + limit),
|
|
734
|
+
offset,
|
|
735
|
+
limit,
|
|
736
|
+
totalSize: content.length,
|
|
737
|
+
hasMore: offset + limit < content.length,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
// ── Model-facing tools ─────────────────────────────────────────────────────
|
|
741
|
+
const START_SCHEMA = z.object({
|
|
742
|
+
argv: z
|
|
743
|
+
.array(z.string())
|
|
744
|
+
.describe('The executable followed by one entry per argument: ["pnpm", "run", "lint"]. ' +
|
|
745
|
+
"There is NO shell — pipes, redirects and quoting do nothing here."),
|
|
746
|
+
cwd: z
|
|
747
|
+
.string()
|
|
748
|
+
.optional()
|
|
749
|
+
.describe("Directory to run in. Must be inside the permitted root; defaults to it."),
|
|
750
|
+
timeoutMs: z
|
|
751
|
+
.number()
|
|
752
|
+
.int()
|
|
753
|
+
.positive()
|
|
754
|
+
.optional()
|
|
755
|
+
.describe("Wall-clock budget in milliseconds before the command is killed. " +
|
|
756
|
+
"Capped at the host policy's budget."),
|
|
757
|
+
});
|
|
758
|
+
const STATUS_SCHEMA = z.object({
|
|
759
|
+
taskId: z.string().describe("Task id returned by run_command_bg."),
|
|
760
|
+
waitMs: z
|
|
761
|
+
.number()
|
|
762
|
+
.optional()
|
|
763
|
+
.describe("Wait up to this many milliseconds for the command to finish. Omit to " +
|
|
764
|
+
"read the status as it stands right now."),
|
|
765
|
+
});
|
|
766
|
+
const OUTPUT_SCHEMA = z.object({
|
|
767
|
+
taskId: z.string().describe("Task id returned by run_command_bg."),
|
|
768
|
+
stream: z
|
|
769
|
+
.enum(["stdout", "stderr"])
|
|
770
|
+
.optional()
|
|
771
|
+
.describe('Which stream to read. Default "stdout".'),
|
|
772
|
+
offset: z
|
|
773
|
+
.number()
|
|
774
|
+
.optional()
|
|
775
|
+
.describe("Character offset to start at. Default 0."),
|
|
776
|
+
limit: z
|
|
777
|
+
.number()
|
|
778
|
+
.optional()
|
|
779
|
+
.describe("Maximum characters to return. Default 50000, cap 200000."),
|
|
780
|
+
});
|
|
781
|
+
const KILL_SCHEMA = z.object({
|
|
782
|
+
taskId: z.string().describe("Task id returned by run_command_bg."),
|
|
783
|
+
});
|
|
784
|
+
function withCounts(host, status, sessionId) {
|
|
785
|
+
return { ...status, ...backgroundCommandCounts(host, sessionId) };
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The four model-facing command tools, bound to `host`. Register them with
|
|
789
|
+
* `host.registerTool()` (see `NeuroLink.registerBackgroundCommandTools()`),
|
|
790
|
+
* never on the tool registry directly: only the "user-defined" category
|
|
791
|
+
* reaches the LLM's tool schema.
|
|
792
|
+
*/
|
|
793
|
+
export function createBackgroundCommandTools(host) {
|
|
794
|
+
return {
|
|
795
|
+
run_command_bg: {
|
|
796
|
+
name: "run_command_bg",
|
|
797
|
+
description: "Start a command in the BACKGROUND and get a taskId back immediately — the " +
|
|
798
|
+
"command keeps running while you do other work. Use it for checks whose output " +
|
|
799
|
+
"is evidence: builds, test suites, linters. The complete stdout and stderr are " +
|
|
800
|
+
"written to files and banked, so nothing is ever truncated away; read them with " +
|
|
801
|
+
"command_output. Only allowlisted executables may be run, and there is no shell.",
|
|
802
|
+
inputSchema: START_SCHEMA,
|
|
803
|
+
execute: async (params, executionContext) => {
|
|
804
|
+
const parsed = START_SCHEMA.safeParse(params ?? {});
|
|
805
|
+
if (!parsed.success) {
|
|
806
|
+
return refusal("run_command_bg expects { argv: string[], cwd?, timeoutMs? } with argv " +
|
|
807
|
+
'non-empty — e.g. { argv: ["pnpm", "run", "lint"] }. Call it again in that shape.');
|
|
808
|
+
}
|
|
809
|
+
const policy = hostPolicies.get(host);
|
|
810
|
+
if (!policy) {
|
|
811
|
+
return refusal(NO_POLICY_REFUSAL);
|
|
812
|
+
}
|
|
813
|
+
const sessionId = resolveChecklistSessionId(host, executionContext);
|
|
814
|
+
try {
|
|
815
|
+
// The model may narrow the budget, never widen it: the policy's
|
|
816
|
+
// default is the host's declared ceiling for model-started commands.
|
|
817
|
+
const budget = policy.defaultTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
818
|
+
const handle = await startBackgroundCommand(host, parsed.data.argv, {
|
|
819
|
+
cwd: parsed.data.cwd ?? policy.cwdRoot,
|
|
820
|
+
sessionId,
|
|
821
|
+
...(parsed.data.timeoutMs !== undefined && {
|
|
822
|
+
timeoutMs: Math.min(Math.max(1, parsed.data.timeoutMs), budget),
|
|
823
|
+
}),
|
|
824
|
+
});
|
|
825
|
+
const result = {
|
|
826
|
+
...handle,
|
|
827
|
+
...backgroundCommandCounts(host, sessionId),
|
|
828
|
+
};
|
|
829
|
+
return result;
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
return refusal(errorMessage(error));
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
command_status: {
|
|
837
|
+
name: "command_status",
|
|
838
|
+
description: "Check a background command: its state, exit code, how many bytes each stream " +
|
|
839
|
+
"produced, and a bounded tail of the output. Pass waitMs to wait for it to " +
|
|
840
|
+
"finish instead of polling. Once it has finished, stdout and stderr carry " +
|
|
841
|
+
"artifact ids for the COMPLETE output — the tail is orientation, not evidence.",
|
|
842
|
+
inputSchema: STATUS_SCHEMA,
|
|
843
|
+
execute: async (params, executionContext) => {
|
|
844
|
+
const parsed = STATUS_SCHEMA.safeParse(params ?? {});
|
|
845
|
+
if (!parsed.success) {
|
|
846
|
+
return refusal("command_status expects { taskId, waitMs? }. Call it again with the taskId " +
|
|
847
|
+
"run_command_bg returned.");
|
|
848
|
+
}
|
|
849
|
+
const sessionId = resolveChecklistSessionId(host, executionContext);
|
|
850
|
+
try {
|
|
851
|
+
const status = parsed.data.waitMs === undefined
|
|
852
|
+
? getBackgroundCommandStatus(host, parsed.data.taskId)
|
|
853
|
+
: await awaitBackgroundCommand(host, parsed.data.taskId, {
|
|
854
|
+
timeoutMs: parsed.data.waitMs,
|
|
855
|
+
});
|
|
856
|
+
return withCounts(host, status, sessionId);
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
return refusal(errorMessage(error));
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
command_output: {
|
|
864
|
+
name: "command_output",
|
|
865
|
+
description: "Read a window of a background command's output, by character offset. Works " +
|
|
866
|
+
"while the command is still running and after it finished. totalSize and " +
|
|
867
|
+
"hasMore tell you how much is left; page forward by advancing offset.",
|
|
868
|
+
inputSchema: OUTPUT_SCHEMA,
|
|
869
|
+
execute: async (params) => {
|
|
870
|
+
const parsed = OUTPUT_SCHEMA.safeParse(params ?? {});
|
|
871
|
+
if (!parsed.success) {
|
|
872
|
+
return refusal('command_output expects { taskId, stream?: "stdout" | "stderr", offset?, limit? }. ' +
|
|
873
|
+
"Call it again with the taskId run_command_bg returned.");
|
|
874
|
+
}
|
|
875
|
+
try {
|
|
876
|
+
return await readBackgroundCommandOutput(host, parsed.data.taskId, {
|
|
877
|
+
stream: parsed.data.stream ?? "stdout",
|
|
878
|
+
...(parsed.data.offset !== undefined && {
|
|
879
|
+
offset: parsed.data.offset,
|
|
880
|
+
}),
|
|
881
|
+
...(parsed.data.limit !== undefined && {
|
|
882
|
+
limit: parsed.data.limit,
|
|
883
|
+
}),
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
return refusal(errorMessage(error));
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
command_kill: {
|
|
892
|
+
name: "command_kill",
|
|
893
|
+
description: "Stop a background command you no longer need. Whatever it printed before it " +
|
|
894
|
+
"stopped is still banked and still readable — killing a command discards the " +
|
|
895
|
+
"process, never its output.",
|
|
896
|
+
inputSchema: KILL_SCHEMA,
|
|
897
|
+
execute: async (params, executionContext) => {
|
|
898
|
+
const parsed = KILL_SCHEMA.safeParse(params ?? {});
|
|
899
|
+
if (!parsed.success) {
|
|
900
|
+
return refusal("command_kill expects { taskId }. Call it again with the taskId " +
|
|
901
|
+
"run_command_bg returned.");
|
|
902
|
+
}
|
|
903
|
+
const sessionId = resolveChecklistSessionId(host, executionContext);
|
|
904
|
+
try {
|
|
905
|
+
const status = await killBackgroundCommand(host, parsed.data.taskId);
|
|
906
|
+
return withCounts(host, status, sessionId);
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
return refusal(errorMessage(error));
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|