@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/agent/agentToolRegistrar.d.ts +30 -0
  3. package/dist/agent/agentToolRegistrar.js +72 -18
  4. package/dist/agent/backgroundCommands.d.ts +110 -0
  5. package/dist/agent/backgroundCommands.js +914 -0
  6. package/dist/agent/backgroundDelegation.d.ts +87 -0
  7. package/dist/agent/backgroundDelegation.js +753 -0
  8. package/dist/agent/gitTools.d.ts +43 -0
  9. package/dist/agent/gitTools.js +618 -0
  10. package/dist/agent/taskChecklist.d.ts +58 -0
  11. package/dist/agent/taskChecklist.js +322 -0
  12. package/dist/artifacts/artifactBanking.d.ts +57 -0
  13. package/dist/artifacts/artifactBanking.js +123 -0
  14. package/dist/artifacts/artifactStore.d.ts +36 -8
  15. package/dist/artifacts/artifactStore.js +164 -13
  16. package/dist/browser/neurolink.min.js +442 -414
  17. package/dist/cli/commands/setup.js +2 -1
  18. package/dist/constants/enums.d.ts +19 -0
  19. package/dist/constants/enums.js +20 -0
  20. package/dist/factories/providerDescriptors.js +16 -1
  21. package/dist/models/manifestRegistry.js +2 -0
  22. package/dist/models/manifests/cerebras.d.ts +9 -0
  23. package/dist/models/manifests/cerebras.js +19 -0
  24. package/dist/neurolink.d.ts +294 -3
  25. package/dist/neurolink.js +447 -4
  26. package/dist/providers/openaiCompatCatalog.d.ts +1 -1
  27. package/dist/providers/openaiCompatCatalog.js +34 -3
  28. package/dist/types/artifact.d.ts +54 -0
  29. package/dist/types/backgroundCommand.d.ts +174 -0
  30. package/dist/types/backgroundCommand.js +22 -0
  31. package/dist/types/delegation.d.ts +178 -0
  32. package/dist/types/delegation.js +18 -0
  33. package/dist/types/gitTools.d.ts +69 -0
  34. package/dist/types/gitTools.js +22 -0
  35. package/dist/types/index.d.ts +5 -0
  36. package/dist/types/index.js +8 -0
  37. package/dist/types/pathSandbox.d.ts +23 -0
  38. package/dist/types/pathSandbox.js +12 -0
  39. package/dist/types/providers.d.ts +4 -0
  40. package/dist/types/tasks.d.ts +85 -0
  41. package/dist/types/tasks.js +14 -0
  42. package/dist/types/tools.d.ts +11 -0
  43. package/dist/utils/modelChoices.js +17 -1
  44. package/dist/utils/pathSandbox.d.ts +49 -0
  45. package/dist/utils/pathSandbox.js +127 -0
  46. package/dist/utils/providerConfig.d.ts +4 -0
  47. package/dist/utils/providerConfig.js +17 -0
  48. 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
+ }