@crewhaus/tool-proc 0.7.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/dist/index.js ADDED
@@ -0,0 +1,972 @@
1
+ /**
2
+ * @crewhaus/tool-proc — running programs, and waiting for the world.
3
+ *
4
+ * Two concerns live here. The first is running a program: `RunCommand`,
5
+ * `RunPipeline` and `Retry` in the foreground, and the `Process*` family for
6
+ * things that must outlive a single tool call. The second is waiting:
7
+ * `WaitForPort`, `WaitForFile` and `WaitForOutput` turn "sleep 5 and hope"
8
+ * into a stated condition with a stated deadline.
9
+ *
10
+ * Three rules hold across the package.
11
+ *
12
+ * 1. There is no shell. Every command is an argv ARRAY handed straight to
13
+ * the OS, so a model-supplied argument is an argument — never syntax.
14
+ * 2. Every wait has a required deadline, and every spawn has a timeout. A
15
+ * tool that can hang forever is a defect, so none of them can.
16
+ * 3. Determinism means the same inputs against the same world produce the
17
+ * same output: no unseeded randomness, no unordered listings, and a
18
+ * pinned child environment (see ./lib/env) so a program's own output
19
+ * does not drift with the machine's locale or timezone.
20
+ *
21
+ * Results are compact JSON, because every byte returned is a byte in
22
+ * somebody's context window, and a caller's mistake comes back as a
23
+ * readable string rather than an exception.
24
+ */
25
+ import { accessSync, constants as fsConstants, statSync } from "node:fs";
26
+ import { connect } from "node:net";
27
+ import * as path from "node:path";
28
+ import { buildTool } from "@crewhaus/tool-builder";
29
+ import { z } from "zod";
30
+ import { backoffDelayMs, totalBackoffMs } from "./lib/backoff";
31
+ import { FALLBACK_PATH, buildSpawnEnv, inspectEnv } from "./lib/env";
32
+ import { capText, compileSafePattern, formatArgv } from "./lib/format";
33
+ import { __resetRegistryForTest, getProc, listProcs, markKilled, reapProc, startBackground, } from "./registry";
34
+ import { recheckContainment, resolveSafe, resolveSafeDir } from "./safe-path";
35
+ import { runOnce, sleep } from "./spawn";
36
+ export { __resetRegistryForTest };
37
+ /** Compact JSON — the reader is a model, not a person. */
38
+ const json = (value) => JSON.stringify(value);
39
+ const DEFAULT_TIMEOUT_MS = 30_000;
40
+ const MAX_TIMEOUT_MS = 600_000;
41
+ /** The longest any wait may be asked to block, deadline or not. */
42
+ const MAX_WAIT_MS = 600_000;
43
+ const DEFAULT_MAX_OUTPUT = 100_000;
44
+ const MAX_MAX_OUTPUT = 1_000_000;
45
+ const DEFAULT_POLL_MS = 100;
46
+ // ---------------------------------------------------------------------------
47
+ // shared schema pieces
48
+ // ---------------------------------------------------------------------------
49
+ const argvSchema = z
50
+ .array(z.string())
51
+ .min(1)
52
+ .max(128)
53
+ .describe('the program and each argument as separate strings, e.g. ["git","log","-n","1"] — never a single shell string');
54
+ const envForwardSchema = z
55
+ .array(z.string())
56
+ .max(64)
57
+ .optional()
58
+ .describe("names of harness environment variables to forward; nothing else is inherited, so secrets stay out of the child unless named");
59
+ const envSetSchema = z
60
+ .record(z.string())
61
+ .optional()
62
+ .describe("literal environment values for the child; these win over forwarded ones");
63
+ const timeoutSchema = z
64
+ .number()
65
+ .int()
66
+ .min(1)
67
+ .max(MAX_TIMEOUT_MS)
68
+ .optional()
69
+ .describe(`milliseconds before the command is SIGTERMed (default ${DEFAULT_TIMEOUT_MS})`);
70
+ const maxOutputSchema = z.number().int().min(100).max(MAX_MAX_OUTPUT).optional();
71
+ const deadlineSchema = (what) => z
72
+ .number()
73
+ .int()
74
+ .min(1)
75
+ .max(MAX_WAIT_MS)
76
+ .describe(`required deadline in milliseconds — ${what} never waits longer than this`);
77
+ const intervalSchema = z
78
+ .number()
79
+ .int()
80
+ .min(10)
81
+ .max(60_000)
82
+ .optional()
83
+ .describe(`milliseconds between checks (default ${DEFAULT_POLL_MS})`);
84
+ function prepare(toolName, input) {
85
+ let cwd = process.cwd();
86
+ if (input.cwd !== undefined) {
87
+ const resolved = resolveSafeDir(toolName, input.cwd);
88
+ if (!resolved.ok)
89
+ return { ok: false, message: resolved.message };
90
+ cwd = resolved.path;
91
+ }
92
+ const built = buildSpawnEnv(process.env, {
93
+ forward: input.env ?? [],
94
+ set: input.envSet ?? {},
95
+ });
96
+ if (built.invalid.length > 0) {
97
+ return {
98
+ ok: false,
99
+ message: `[${toolName} error] not environment-variable names: ${built.invalid.join(", ")}`,
100
+ };
101
+ }
102
+ return { ok: true, cwd, env: built.env, envMissing: built.missing };
103
+ }
104
+ /** argv itself must name a program; an empty argv[0] would spawn nothing. */
105
+ function checkArgv(toolName, argv) {
106
+ const program = argv[0];
107
+ if (program === undefined || program.trim() === "") {
108
+ return `[${toolName} error] argv[0] must name a program to run.`;
109
+ }
110
+ for (const arg of argv) {
111
+ if (arg.includes("\0"))
112
+ return `[${toolName} error] argv may not contain a NUL byte.`;
113
+ }
114
+ return undefined;
115
+ }
116
+ /** The fields every foreground run reports, in one shape. */
117
+ function outcomeJson(outcome) {
118
+ return {
119
+ argv: outcome.argv,
120
+ ...(outcome.spawnError !== undefined ? { spawnError: outcome.spawnError } : {}),
121
+ ...(outcome.abandoned === true ? { abandoned: true } : {}),
122
+ exitCode: outcome.exitCode,
123
+ ok: outcome.spawnError === undefined && outcome.abandoned !== true && outcome.exitCode === 0,
124
+ timedOut: outcome.timedOut,
125
+ // Wall-clock duration is reported because measuring the run is part of
126
+ // what a caller asks RunCommand for; nothing else here reads a clock.
127
+ durationMs: outcome.durationMs,
128
+ stdout: outcome.stdout,
129
+ stderr: outcome.stderr,
130
+ ...(outcome.stdoutTruncated ? { stdoutTruncated: true } : {}),
131
+ ...(outcome.stderrTruncated ? { stderrTruncated: true } : {}),
132
+ };
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // running programs
136
+ // ---------------------------------------------------------------------------
137
+ export const runCommand = buildTool({
138
+ name: "RunCommand",
139
+ description: "Run a program from an argv array — the program and each argument as separate strings, with no shell anywhere, so an argument containing a space, a quote or $(...) stays an argument. Use it whenever a harness needs a program's exit code and output without the injection surface of a shell command line. The child inherits no environment except the names you forward, and always has a timeout.",
140
+ inputSchema: z.object({
141
+ argv: argvSchema,
142
+ cwd: z.string().optional().describe("working directory, inside the workspace root"),
143
+ env: envForwardSchema,
144
+ envSet: envSetSchema,
145
+ stdin: z.string().max(1_000_000).optional(),
146
+ timeoutMs: timeoutSchema,
147
+ maxOutputChars: maxOutputSchema,
148
+ }),
149
+ destructive: true,
150
+ scope: "external",
151
+ ioCapability: "process",
152
+ execute: async (input, ctx) => {
153
+ const bad = checkArgv("RunCommand", input.argv);
154
+ if (bad !== undefined)
155
+ return bad;
156
+ const prep = prepare("RunCommand", input);
157
+ if (!prep.ok)
158
+ return prep.message;
159
+ const outcome = await runOnce(input.argv, {
160
+ cwd: prep.cwd,
161
+ env: prep.env,
162
+ ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
163
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
164
+ maxOutputChars: input.maxOutputChars ?? DEFAULT_MAX_OUTPUT,
165
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
166
+ });
167
+ return json({
168
+ ...outcomeJson(outcome),
169
+ ...(prep.envMissing.length > 0 ? { envNotSet: prep.envMissing } : {}),
170
+ });
171
+ },
172
+ });
173
+ export const runPipeline = buildTool({
174
+ name: "RunPipeline",
175
+ description: "Run several argv commands in order, stopping at the first non-zero exit, and return every step's result. Use it for a short ordered chain — install, then build, then test — without spending a model turn between the steps. Steps run in sequence and do not pipe into each other; each gets its own stdin and its own timeout.",
176
+ inputSchema: z.object({
177
+ steps: z
178
+ .array(z.object({
179
+ argv: argvSchema,
180
+ cwd: z.string().optional(),
181
+ stdin: z.string().max(1_000_000).optional(),
182
+ label: z.string().max(80).optional(),
183
+ }))
184
+ .min(1)
185
+ .max(20),
186
+ cwd: z.string().optional().describe("default working directory for every step"),
187
+ env: envForwardSchema,
188
+ envSet: envSetSchema,
189
+ timeoutMs: timeoutSchema.describe("per-step timeout in milliseconds"),
190
+ maxOutputChars: maxOutputSchema,
191
+ continueOnError: z
192
+ .boolean()
193
+ .optional()
194
+ .describe("run every step even after one fails (default false)"),
195
+ }),
196
+ destructive: true,
197
+ scope: "external",
198
+ ioCapability: "process",
199
+ execute: async (input, ctx) => {
200
+ for (const step of input.steps) {
201
+ const bad = checkArgv("RunPipeline", step.argv);
202
+ if (bad !== undefined)
203
+ return bad;
204
+ }
205
+ const prep = prepare("RunPipeline", input);
206
+ if (!prep.ok)
207
+ return prep.message;
208
+ // Resolve EVERY step's cwd before running any of them. Resolving inside
209
+ // the loop meant a bad cwd on step 3 was only discovered after steps 1
210
+ // and 2 had already had their effects, and the refusal then replaced the
211
+ // whole result — so the caller was told nothing about what did run.
212
+ const cwds = [];
213
+ for (const step of input.steps) {
214
+ if (step.cwd === undefined) {
215
+ cwds.push(prep.cwd);
216
+ continue;
217
+ }
218
+ const resolved = resolveSafeDir("RunPipeline", step.cwd);
219
+ if (!resolved.ok)
220
+ return resolved.message;
221
+ cwds.push(resolved.path);
222
+ }
223
+ const results = [];
224
+ let failedAt = null;
225
+ for (const [index, step] of input.steps.entries()) {
226
+ const cwd = cwds[index];
227
+ const outcome = await runOnce(step.argv, {
228
+ cwd,
229
+ env: prep.env,
230
+ ...(step.stdin !== undefined ? { stdin: step.stdin } : {}),
231
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
232
+ maxOutputChars: input.maxOutputChars ?? DEFAULT_MAX_OUTPUT,
233
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
234
+ });
235
+ results.push({
236
+ step: index,
237
+ ...(step.label !== undefined ? { label: step.label } : {}),
238
+ ...outcomeJson(outcome),
239
+ });
240
+ const failed = outcome.spawnError !== undefined || outcome.exitCode !== 0;
241
+ if (failed && failedAt === null)
242
+ failedAt = index;
243
+ if (failed && input.continueOnError !== true)
244
+ break;
245
+ if (ctx?.signal?.aborted === true)
246
+ break;
247
+ }
248
+ return json({
249
+ steps: input.steps.length,
250
+ ran: results.length,
251
+ ok: failedAt === null,
252
+ ...(failedAt !== null ? { failedAtStep: failedAt } : {}),
253
+ results,
254
+ ...(prep.envMissing.length > 0 ? { envNotSet: prep.envMissing } : {}),
255
+ });
256
+ },
257
+ });
258
+ const backoffSchema = z
259
+ .union([
260
+ z.object({
261
+ kind: z.literal("fixed"),
262
+ delayMs: z.number().int().min(0).max(60_000),
263
+ }),
264
+ z.object({
265
+ kind: z.literal("exponential"),
266
+ baseMs: z.number().int().min(0).max(60_000),
267
+ factor: z.number().min(1).max(10).optional().describe("multiplier per attempt (default 2)"),
268
+ maxDelayMs: z.number().int().min(0).max(300_000).optional(),
269
+ }),
270
+ ])
271
+ .describe("the wait between attempts; declared by the caller, with no jitter, so a retry plan replays identically");
272
+ export const retry = buildTool({
273
+ name: "Retry",
274
+ description: "Re-run an argv command until it succeeds or a bounded attempt count runs out, waiting a caller-declared backoff between attempts. Use it for a flaky step — a service still starting, a lock still held — instead of asking a model to decide when to try again. The backoff is fixed or exponential with an explicit base and carries no jitter, and every attempt is reported.",
275
+ inputSchema: z.object({
276
+ argv: argvSchema,
277
+ maxAttempts: z.number().int().min(1).max(10),
278
+ backoff: backoffSchema,
279
+ cwd: z.string().optional(),
280
+ env: envForwardSchema,
281
+ envSet: envSetSchema,
282
+ stdin: z.string().max(1_000_000).optional(),
283
+ timeoutMs: timeoutSchema.describe("per-attempt timeout in milliseconds"),
284
+ maxOutputChars: maxOutputSchema.describe("per-attempt output cap (default 20000)"),
285
+ successExitCodes: z
286
+ .array(z.number().int().min(0).max(255))
287
+ .max(16)
288
+ .optional()
289
+ .describe("exit codes that count as success (default [0])"),
290
+ deadlineMs: z
291
+ .number()
292
+ .int()
293
+ .min(1)
294
+ .max(MAX_WAIT_MS)
295
+ .optional()
296
+ .describe("overall deadline; no new attempt starts once it has passed"),
297
+ }),
298
+ destructive: true,
299
+ scope: "external",
300
+ ioCapability: "process",
301
+ execute: async (input, ctx) => {
302
+ const bad = checkArgv("Retry", input.argv);
303
+ if (bad !== undefined)
304
+ return bad;
305
+ const prep = prepare("Retry", input);
306
+ if (!prep.ok)
307
+ return prep.message;
308
+ const policy = input.backoff;
309
+ if (input.deadlineMs !== undefined) {
310
+ const waiting = totalBackoffMs(policy, input.maxAttempts);
311
+ if (waiting >= input.deadlineMs) {
312
+ return `[Retry error] the declared backoff waits ${waiting}ms across ${input.maxAttempts} attempts, which already exceeds the ${input.deadlineMs}ms deadline — shorten the backoff or raise the deadline.`;
313
+ }
314
+ }
315
+ const success = new Set(input.successExitCodes ?? [0]);
316
+ const startedAt = Date.now();
317
+ const attempts = [];
318
+ let succeeded = false;
319
+ let stoppedBy = null;
320
+ for (let attempt = 1; attempt <= input.maxAttempts; attempt++) {
321
+ const delay = backoffDelayMs(policy, attempt);
322
+ if (delay > 0) {
323
+ if (input.deadlineMs !== undefined && Date.now() - startedAt + delay >= input.deadlineMs) {
324
+ stoppedBy = "deadline";
325
+ break;
326
+ }
327
+ await sleep(delay, ctx?.signal);
328
+ }
329
+ if (ctx?.signal?.aborted === true) {
330
+ stoppedBy = "aborted";
331
+ break;
332
+ }
333
+ if (input.deadlineMs !== undefined && Date.now() - startedAt >= input.deadlineMs) {
334
+ stoppedBy = "deadline";
335
+ break;
336
+ }
337
+ const outcome = await runOnce(input.argv, {
338
+ cwd: prep.cwd,
339
+ env: prep.env,
340
+ ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
341
+ timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
342
+ maxOutputChars: input.maxOutputChars ?? 20_000,
343
+ ...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
344
+ });
345
+ const ok = outcome.spawnError === undefined && success.has(outcome.exitCode);
346
+ attempts.push({
347
+ attempt,
348
+ waitedBeforeMs: delay,
349
+ exitCode: outcome.exitCode,
350
+ ok,
351
+ timedOut: outcome.timedOut,
352
+ ...(outcome.spawnError !== undefined ? { spawnError: outcome.spawnError } : {}),
353
+ stdout: outcome.stdout,
354
+ stderr: outcome.stderr,
355
+ });
356
+ if (ok) {
357
+ succeeded = true;
358
+ break;
359
+ }
360
+ }
361
+ return json({
362
+ argv: input.argv,
363
+ succeeded,
364
+ attemptsUsed: attempts.length,
365
+ maxAttempts: input.maxAttempts,
366
+ ...(stoppedBy !== null ? { stoppedBy } : {}),
367
+ attempts,
368
+ });
369
+ },
370
+ });
371
+ // ---------------------------------------------------------------------------
372
+ // background processes
373
+ // ---------------------------------------------------------------------------
374
+ const procIdSchema = z.string().min(1).describe("an id returned by ProcessStart, e.g. proc_1");
375
+ function procStateJson(bg) {
376
+ return {
377
+ id: bg.id,
378
+ ...(bg.label !== undefined ? { label: bg.label } : {}),
379
+ command: formatArgv(bg.argv),
380
+ status: bg.status,
381
+ exitCode: bg.exitCode,
382
+ pendingStdoutChars: bg.stdout.length - bg.stdoutReturned,
383
+ pendingStderrChars: bg.stderr.length - bg.stderrReturned,
384
+ ...(bg.truncated ? { earlierOutputDropped: true } : {}),
385
+ };
386
+ }
387
+ function noSuchProc(toolName, id) {
388
+ const known = listProcs().map((p) => p.id);
389
+ return `[${toolName} error] no background process with id "${id}". Known ids: ${known.length === 0 ? "(none)" : known.join(", ")}.`;
390
+ }
391
+ export const processStart = buildTool({
392
+ name: "ProcessStart",
393
+ description: "Start a program in the background from an argv array and return an id immediately, without waiting for it to finish. Use it for something that must outlive one tool call — a dev server, a watcher, a tail — then poll it with ProcessStatus and ProcessOutput and end it with ProcessStop. The process is killed if the harness exits, so a session never leaks children.",
394
+ inputSchema: z.object({
395
+ argv: argvSchema,
396
+ cwd: z.string().optional(),
397
+ env: envForwardSchema,
398
+ envSet: envSetSchema,
399
+ stdin: z.string().max(1_000_000).optional(),
400
+ label: z.string().max(80).optional().describe("a name for this process in listings"),
401
+ }),
402
+ destructive: true,
403
+ scope: "external",
404
+ ioCapability: "process",
405
+ execute: async (input) => {
406
+ const bad = checkArgv("ProcessStart", input.argv);
407
+ if (bad !== undefined)
408
+ return bad;
409
+ const prep = prepare("ProcessStart", input);
410
+ if (!prep.ok)
411
+ return prep.message;
412
+ const started = startBackground(input.argv, {
413
+ cwd: prep.cwd,
414
+ env: prep.env,
415
+ ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
416
+ ...(input.label !== undefined ? { label: input.label } : {}),
417
+ });
418
+ if (!started.ok)
419
+ return `[ProcessStart error] ${started.message}`;
420
+ return json({
421
+ ...procStateJson(started.proc),
422
+ ...(started.evicted > 0 ? { evictedFinishedEntries: started.evicted } : {}),
423
+ note: "poll with ProcessOutput; stop with ProcessStop",
424
+ });
425
+ },
426
+ });
427
+ export const processStatus = buildTool({
428
+ name: "ProcessStatus",
429
+ description: "Report whether a background process is still running, and its exit code once it is not. Use it to check on a ProcessStart id without consuming its buffered output, which ProcessOutput would. It reports no timestamps, so the same state answers with the same bytes.",
430
+ inputSchema: z.object({ id: procIdSchema }),
431
+ readOnly: true,
432
+ concurrencySafe: true,
433
+ execute: async (input) => {
434
+ const bg = getProc(input.id);
435
+ if (bg === undefined)
436
+ return noSuchProc("ProcessStatus", input.id);
437
+ return json(procStateJson(bg));
438
+ },
439
+ });
440
+ export const processOutput = buildTool({
441
+ name: "ProcessOutput",
442
+ description: "Return what a background process has written since the last poll, and its current status. Use it to follow a long-running process incrementally instead of buffering everything until it exits. Each call advances a per-stream cursor, so output is returned exactly once — use ProcessStatus for a look that consumes nothing.",
443
+ inputSchema: z.object({
444
+ id: procIdSchema,
445
+ stream: z.enum(["stdout", "stderr", "both"]).optional(),
446
+ maxChars: maxOutputSchema.describe("cap per stream for this poll (default 100000)"),
447
+ }),
448
+ // Advancing a read cursor is state: not read-only, and not safe to run
449
+ // beside a sibling that would consume the same bytes.
450
+ execute: async (input) => {
451
+ const bg = getProc(input.id);
452
+ if (bg === undefined)
453
+ return noSuchProc("ProcessOutput", input.id);
454
+ const which = input.stream ?? "both";
455
+ const cap = input.maxChars ?? DEFAULT_MAX_OUTPUT;
456
+ const result = { id: bg.id, status: bg.status, exitCode: bg.exitCode };
457
+ if (which === "stdout" || which === "both") {
458
+ const fresh = capText(bg.stdout.slice(bg.stdoutReturned), cap);
459
+ bg.stdoutReturned = bg.stdout.length;
460
+ result["stdout"] = fresh.text;
461
+ if (fresh.truncated)
462
+ result["stdoutTruncated"] = true;
463
+ }
464
+ if (which === "stderr" || which === "both") {
465
+ const fresh = capText(bg.stderr.slice(bg.stderrReturned), cap);
466
+ bg.stderrReturned = bg.stderr.length;
467
+ result["stderr"] = fresh.text;
468
+ if (fresh.truncated)
469
+ result["stderrTruncated"] = true;
470
+ }
471
+ if (bg.truncated)
472
+ result["earlierOutputDropped"] = true;
473
+ return json(result);
474
+ },
475
+ });
476
+ export const processStop = buildTool({
477
+ name: "ProcessStop",
478
+ description: "Stop a background process: signal it, wait a bounded grace period, then SIGKILL it if it is still alive. Use it to end a dev server or watcher deterministically rather than leaving it running past the task. Pass reap to drop the entry entirely; otherwise it stays readable so its final output can still be collected.",
479
+ inputSchema: z.object({
480
+ id: procIdSchema,
481
+ signal: z.enum(["SIGTERM", "SIGINT", "SIGHUP", "SIGKILL"]).optional(),
482
+ killAfterMs: z
483
+ .number()
484
+ .int()
485
+ .min(0)
486
+ .max(60_000)
487
+ .optional()
488
+ .describe("grace period before escalating to SIGKILL (default 5000)"),
489
+ reap: z.boolean().optional().describe("forget the process entirely once it is stopped"),
490
+ }),
491
+ destructive: true,
492
+ // Signalling an OS process is an effect the runtime cannot re-classify
493
+ // afterwards, exactly like starting one — so this declares the same
494
+ // capability ProcessStart does rather than passing as a registry edit.
495
+ scope: "external",
496
+ ioCapability: "process",
497
+ execute: async (input) => {
498
+ const bg = getProc(input.id);
499
+ if (bg === undefined)
500
+ return noSuchProc("ProcessStop", input.id);
501
+ if (bg.status !== "running") {
502
+ const reaped = input.reap === true ? reapProc(bg.id) : false;
503
+ return json({
504
+ id: bg.id,
505
+ stopped: false,
506
+ alreadyFinished: true,
507
+ status: bg.status,
508
+ exitCode: bg.exitCode,
509
+ reaped,
510
+ });
511
+ }
512
+ const signal = input.signal ?? "SIGTERM";
513
+ const grace = input.killAfterMs ?? 5_000;
514
+ try {
515
+ bg.proc.kill(signal);
516
+ }
517
+ catch (err) {
518
+ return `[ProcessStop error] could not signal ${bg.id}: ${err instanceof Error ? err.message : String(err)}`;
519
+ }
520
+ // Record intent before awaiting: the exit handler only fills in the code,
521
+ // so the status stays "killed" rather than looking like a natural exit.
522
+ markKilled(bg);
523
+ let escalated = false;
524
+ const exited = bg.proc.exited.then(() => true);
525
+ const finished = await Promise.race([exited, sleep(grace).then(() => false)]);
526
+ if (!finished) {
527
+ escalated = true;
528
+ try {
529
+ bg.proc.kill("SIGKILL");
530
+ }
531
+ catch {
532
+ // Gone between the race and the kill.
533
+ }
534
+ // SIGKILL cannot be ignored, but still bound the wait rather than
535
+ // blocking the turn on an unreapable child.
536
+ await Promise.race([exited, sleep(2_000).then(() => false)]);
537
+ }
538
+ const reaped = input.reap === true ? reapProc(bg.id) : false;
539
+ return json({
540
+ id: bg.id,
541
+ stopped: true,
542
+ signal,
543
+ escalatedToSigkill: escalated,
544
+ status: bg.status,
545
+ exitCode: bg.exitCode,
546
+ reaped,
547
+ });
548
+ },
549
+ });
550
+ export const processList = buildTool({
551
+ name: "ProcessList",
552
+ description: "List the background processes this harness started, in start order, with their status and how much output is waiting. Use it to find an id you have lost or to check nothing was left running at the end of a task. It shows only processes started through ProcessStart — never the machine's other processes.",
553
+ inputSchema: z.object({
554
+ includeFinished: z
555
+ .boolean()
556
+ .optional()
557
+ .describe("include processes that have exited or been killed (default true)"),
558
+ }),
559
+ readOnly: true,
560
+ concurrencySafe: true,
561
+ execute: async (input) => {
562
+ const all = listProcs();
563
+ const shown = input.includeFinished === false ? all.filter((p) => p.status === "running") : all;
564
+ return json({
565
+ count: shown.length,
566
+ running: all.filter((p) => p.status === "running").length,
567
+ processes: shown.map(procStateJson),
568
+ });
569
+ },
570
+ });
571
+ // ---------------------------------------------------------------------------
572
+ // waiting
573
+ // ---------------------------------------------------------------------------
574
+ /**
575
+ * One TCP connect attempt. Resolves true when the port accepts, false on
576
+ * refusal, unreachability or its own short timeout — the socket is always
577
+ * destroyed, so a poll loop cannot leak descriptors.
578
+ */
579
+ function probePort(host, port, timeoutMs) {
580
+ return new Promise((resolve) => {
581
+ let socket;
582
+ try {
583
+ socket = connect({ host, port });
584
+ }
585
+ catch {
586
+ // A host the resolver rejects outright: an answer, not an exception to
587
+ // throw out of a tool call.
588
+ resolve(false);
589
+ return;
590
+ }
591
+ let settled = false;
592
+ const done = (open) => {
593
+ if (settled)
594
+ return;
595
+ settled = true;
596
+ clearTimeout(hardStop);
597
+ try {
598
+ socket.destroy();
599
+ }
600
+ catch {
601
+ // Already destroyed.
602
+ }
603
+ resolve(open);
604
+ };
605
+ // The socket's own timeout is the normal bound; this one exists because
606
+ // the probe is the only part of the poll loop the deadline cannot
607
+ // interrupt, and a socket that emitted none of the four events below
608
+ // would hang the whole wait. `done` is never called before this line —
609
+ // every caller of it is an event handler or this timer.
610
+ const hardStop = setTimeout(() => done(false), timeoutMs + 1_000);
611
+ socket.setTimeout(timeoutMs);
612
+ socket.once("connect", () => done(true));
613
+ socket.once("timeout", () => done(false));
614
+ socket.once("error", () => done(false));
615
+ socket.once("close", () => done(false));
616
+ });
617
+ }
618
+ const HOSTNAME = /^[A-Za-z0-9._:-]+$/;
619
+ export const waitForPort = buildTool({
620
+ name: "WaitForPort",
621
+ description: "Poll a TCP host and port until it is accepting connections, or until it stops, within a required deadline. Use it to wait for a server the harness just started to be ready, instead of guessing with a sleep. It reports whether the condition was met and how many probes it took, and never waits past the deadline.",
622
+ inputSchema: z.object({
623
+ port: z.number().int().min(1).max(65_535),
624
+ host: z.string().max(255).optional().describe("default 127.0.0.1"),
625
+ state: z.enum(["open", "closed"]).optional().describe("the state to wait for (default open)"),
626
+ timeoutMs: deadlineSchema("the poll"),
627
+ intervalMs: intervalSchema,
628
+ }),
629
+ readOnly: true,
630
+ concurrencySafe: true,
631
+ // Opening a socket crosses a network boundary even though nothing is sent
632
+ // on it, so the egress fabric must see this call.
633
+ scope: "external",
634
+ ioCapability: "network",
635
+ execute: async (input, ctx) => {
636
+ const host = input.host ?? "127.0.0.1";
637
+ if (!HOSTNAME.test(host)) {
638
+ return `[WaitForPort error] "${host}" is not a hostname or address.`;
639
+ }
640
+ const want = input.state ?? "open";
641
+ const interval = input.intervalMs ?? DEFAULT_POLL_MS;
642
+ const startedAt = Date.now();
643
+ const deadline = startedAt + input.timeoutMs;
644
+ let attempts = 0;
645
+ let open = false;
646
+ while (Date.now() < deadline) {
647
+ const remaining = deadline - Date.now();
648
+ attempts++;
649
+ open = await probePort(host, input.port, Math.max(1, Math.min(1_000, remaining)));
650
+ if ((want === "open") === open) {
651
+ return json({
652
+ satisfied: true,
653
+ state: open ? "open" : "closed",
654
+ host,
655
+ port: input.port,
656
+ attempts,
657
+ waitedMs: Date.now() - startedAt,
658
+ });
659
+ }
660
+ if (ctx?.signal?.aborted === true)
661
+ break;
662
+ const left = deadline - Date.now();
663
+ if (left <= 0)
664
+ break;
665
+ await sleep(Math.min(interval, left), ctx?.signal);
666
+ }
667
+ return json({
668
+ satisfied: false,
669
+ state: open ? "open" : "closed",
670
+ wanted: want,
671
+ host,
672
+ port: input.port,
673
+ attempts,
674
+ waitedMs: Date.now() - startedAt,
675
+ reason: ctx?.signal?.aborted === true ? "aborted" : "deadline",
676
+ });
677
+ },
678
+ });
679
+ export const waitForFile = buildTool({
680
+ name: "WaitForFile",
681
+ description: "Wait for a workspace path to appear, to disappear, or to stop changing size, within a required deadline. Use it to hand off to a process that writes a file — a build artifact, a lock, a downloaded asset — without polling by hand. The path must stay inside the workspace root, and the wait always ends at the deadline.",
682
+ inputSchema: z.object({
683
+ path: z.string().min(1),
684
+ condition: z
685
+ .enum(["exists", "absent", "stable"])
686
+ .optional()
687
+ .describe("'stable' means present and unchanged in size for stableForMs (default exists)"),
688
+ timeoutMs: deadlineSchema("the wait"),
689
+ intervalMs: intervalSchema,
690
+ stableForMs: z
691
+ .number()
692
+ .int()
693
+ .min(10)
694
+ .max(60_000)
695
+ .optional()
696
+ .describe("how long the size must hold still for 'stable' (default 500)"),
697
+ }),
698
+ readOnly: true,
699
+ concurrencySafe: true,
700
+ execute: async (input, ctx) => {
701
+ const resolved = resolveSafe("WaitForFile", input.path);
702
+ if (!resolved.ok)
703
+ return resolved.message;
704
+ const target = resolved.path;
705
+ const condition = input.condition ?? "exists";
706
+ const interval = input.intervalMs ?? DEFAULT_POLL_MS;
707
+ const stableFor = input.stableForMs ?? 500;
708
+ const startedAt = Date.now();
709
+ const deadline = startedAt + input.timeoutMs;
710
+ /**
711
+ * `null` means "nothing there", a number is the size, and `"escaped"`
712
+ * means the path now resolves outside the root. Containment is re-checked
713
+ * on EVERY poll, not just once up front: the leaf may not exist when the
714
+ * wait starts, so the only honest moment to resolve its symlinks is the
715
+ * moment it does.
716
+ */
717
+ const sizeOf = () => {
718
+ const where = recheckContainment(target);
719
+ if (where === "escaped")
720
+ return "escaped";
721
+ if (where === "absent")
722
+ return null;
723
+ try {
724
+ return statSync(target).size;
725
+ }
726
+ catch {
727
+ return null;
728
+ }
729
+ };
730
+ let attempts = 0;
731
+ let lastSize = null;
732
+ let sameSince = 0;
733
+ let size = null;
734
+ while (Date.now() < deadline) {
735
+ attempts++;
736
+ const probed = sizeOf();
737
+ if (probed === "escaped") {
738
+ return `[WaitForFile error] refused path "${input.path}": it now resolves outside the workspace root through a symlink. Paths must stay inside the workspace root.`;
739
+ }
740
+ size = probed;
741
+ const now = Date.now();
742
+ let satisfied = false;
743
+ if (condition === "exists")
744
+ satisfied = size !== null;
745
+ else if (condition === "absent")
746
+ satisfied = size === null;
747
+ else {
748
+ if (size === null) {
749
+ lastSize = null;
750
+ sameSince = 0;
751
+ }
752
+ else if (size !== lastSize) {
753
+ lastSize = size;
754
+ sameSince = now;
755
+ }
756
+ else if (now - sameSince >= stableFor) {
757
+ satisfied = true;
758
+ }
759
+ }
760
+ if (satisfied) {
761
+ return json({
762
+ satisfied: true,
763
+ condition,
764
+ path: input.path,
765
+ exists: size !== null,
766
+ ...(size !== null ? { sizeBytes: size } : {}),
767
+ attempts,
768
+ waitedMs: Date.now() - startedAt,
769
+ });
770
+ }
771
+ if (ctx?.signal?.aborted === true)
772
+ break;
773
+ const left = deadline - Date.now();
774
+ if (left <= 0)
775
+ break;
776
+ await sleep(Math.min(interval, left), ctx?.signal);
777
+ }
778
+ return json({
779
+ satisfied: false,
780
+ condition,
781
+ path: input.path,
782
+ exists: size !== null,
783
+ attempts,
784
+ waitedMs: Date.now() - startedAt,
785
+ reason: ctx?.signal?.aborted === true ? "aborted" : "deadline",
786
+ });
787
+ },
788
+ });
789
+ export const waitForOutput = buildTool({
790
+ name: "WaitForOutput",
791
+ description: "Watch a background process's output until a pattern matches, a failure pattern matches first, or a required deadline passes. Use it to wait for the line that means ready — 'Listening on', 'compiled successfully' — and to give up early when the line that means broken shows up instead. It reads without consuming, so ProcessOutput still returns everything afterwards.",
792
+ inputSchema: z.object({
793
+ id: procIdSchema,
794
+ pattern: z.string().min(1).describe("a JavaScript regular expression source"),
795
+ failurePattern: z
796
+ .string()
797
+ .min(1)
798
+ .optional()
799
+ .describe("when this matches first, stop waiting and report the failure"),
800
+ flags: z.string().max(8).optional().describe("regex flags, e.g. 'i'"),
801
+ stream: z.enum(["stdout", "stderr", "both"]).optional(),
802
+ timeoutMs: deadlineSchema("the watch"),
803
+ intervalMs: intervalSchema,
804
+ }),
805
+ // Matching does not move the output cursor, so two callers can watch the
806
+ // same process without stealing each other's bytes.
807
+ readOnly: true,
808
+ concurrencySafe: true,
809
+ execute: async (input, ctx) => {
810
+ const bg = getProc(input.id);
811
+ if (bg === undefined)
812
+ return noSuchProc("WaitForOutput", input.id);
813
+ const success = compileSafePattern(input.pattern, input.flags ?? "");
814
+ if (!success.ok)
815
+ return `[WaitForOutput error] ${success.message}`;
816
+ let failure;
817
+ if (input.failurePattern !== undefined) {
818
+ const compiled = compileSafePattern(input.failurePattern, input.flags ?? "");
819
+ if (!compiled.ok)
820
+ return `[WaitForOutput error] failurePattern — ${compiled.message}`;
821
+ failure = compiled.regex;
822
+ }
823
+ const which = input.stream ?? "both";
824
+ const interval = input.intervalMs ?? DEFAULT_POLL_MS;
825
+ const startedAt = Date.now();
826
+ const deadline = startedAt + input.timeoutMs;
827
+ const haystack = () => which === "stdout"
828
+ ? bg.stdout
829
+ : which === "stderr"
830
+ ? bg.stderr
831
+ : `${bg.stdout}\n${bg.stderr}`;
832
+ for (;;) {
833
+ const text = haystack();
834
+ const failed = failure?.exec(text) ?? null;
835
+ if (failed !== null) {
836
+ return json({
837
+ matched: false,
838
+ matchedFailure: true,
839
+ id: bg.id,
840
+ match: failed[0],
841
+ status: bg.status,
842
+ exitCode: bg.exitCode,
843
+ waitedMs: Date.now() - startedAt,
844
+ });
845
+ }
846
+ const hit = success.regex.exec(text);
847
+ if (hit !== null) {
848
+ return json({
849
+ matched: true,
850
+ id: bg.id,
851
+ match: hit[0],
852
+ status: bg.status,
853
+ exitCode: bg.exitCode,
854
+ waitedMs: Date.now() - startedAt,
855
+ });
856
+ }
857
+ // A process that has exited will never produce the line; one last look
858
+ // has already happened above, so stop instead of burning the deadline.
859
+ if (bg.status !== "running") {
860
+ return json({
861
+ matched: false,
862
+ id: bg.id,
863
+ reason: "process finished without matching",
864
+ status: bg.status,
865
+ exitCode: bg.exitCode,
866
+ waitedMs: Date.now() - startedAt,
867
+ });
868
+ }
869
+ if (ctx?.signal?.aborted === true) {
870
+ return json({
871
+ matched: false,
872
+ id: bg.id,
873
+ reason: "aborted",
874
+ status: bg.status,
875
+ waitedMs: Date.now() - startedAt,
876
+ });
877
+ }
878
+ const left = deadline - Date.now();
879
+ if (left <= 0) {
880
+ return json({
881
+ matched: false,
882
+ id: bg.id,
883
+ reason: "deadline",
884
+ status: bg.status,
885
+ exitCode: bg.exitCode,
886
+ waitedMs: Date.now() - startedAt,
887
+ });
888
+ }
889
+ await sleep(Math.min(interval, left), ctx?.signal);
890
+ }
891
+ },
892
+ });
893
+ // ---------------------------------------------------------------------------
894
+ // asking about the environment
895
+ // ---------------------------------------------------------------------------
896
+ export const commandExists = buildTool({
897
+ name: "CommandExists",
898
+ description: "Report whether a program is on PATH and where it resolves, without running it. Use it to check a prerequisite before building a plan around it, so a missing binary is a clear answer rather than a failed command. It searches the same PATH RunCommand would use, in order, and returns the first executable match.",
899
+ inputSchema: z.object({
900
+ name: z.string().min(1).max(255).describe("a bare program name such as 'git' — not a path"),
901
+ }),
902
+ readOnly: true,
903
+ concurrencySafe: true,
904
+ execute: async (input) => {
905
+ if (input.name.includes("/") || input.name.includes("\\") || input.name.includes("\0")) {
906
+ return `[CommandExists error] "${input.name}" is a path, not a program name — pass a bare name such as "git".`;
907
+ }
908
+ const raw = process.env["PATH"] ?? FALLBACK_PATH;
909
+ const dirs = raw.split(path.delimiter).filter((d) => d !== "");
910
+ for (const dir of dirs) {
911
+ const candidate = path.join(dir, input.name);
912
+ try {
913
+ if (!statSync(candidate).isFile())
914
+ continue;
915
+ accessSync(candidate, fsConstants.X_OK);
916
+ return json({ name: input.name, found: true, path: candidate, searchedDirs: dirs.length });
917
+ }
918
+ catch {
919
+ // Not here, or not executable — keep looking.
920
+ }
921
+ }
922
+ return json({ name: input.name, found: false, path: null, searchedDirs: dirs.length });
923
+ },
924
+ });
925
+ export const envInspect = buildTool({
926
+ name: "EnvInspect",
927
+ description: "Report whether named environment variables are set, and how long their values are, revealing a value only when the caller names it. Use it to check that a credential or configuration variable is present before running something that needs it, without pulling the secret into context. There is no way to list the environment: a name you do not ask for is a name you learn nothing about.",
928
+ inputSchema: z.object({
929
+ names: z
930
+ .array(z.string().min(1).max(255))
931
+ .min(1)
932
+ .max(64)
933
+ .describe("the explicit allow-list of variables to inspect"),
934
+ reveal: z
935
+ .array(z.string().min(1).max(255))
936
+ .max(16)
937
+ .optional()
938
+ .describe("variables whose actual value should be returned; must also appear in names"),
939
+ }),
940
+ readOnly: true,
941
+ concurrencySafe: true,
942
+ execute: async (input) => {
943
+ const named = new Set(input.names);
944
+ const reveal = input.reveal ?? [];
945
+ const notNamed = reveal.filter((r) => !named.has(r));
946
+ if (notNamed.length > 0) {
947
+ return `[EnvInspect error] reveal lists ${notNamed.join(", ")}, which names does not — add them to names to inspect them.`;
948
+ }
949
+ const { views, invalid } = inspectEnv(process.env, input.names, reveal);
950
+ return json({
951
+ variables: views,
952
+ ...(invalid.length > 0 ? { notEnvironmentNames: invalid } : {}),
953
+ note: "values are withheld unless named in reveal",
954
+ });
955
+ },
956
+ });
957
+ /** Every tool this package registers, in the order a catalog should list them. */
958
+ export const PROC_TOOLS = Object.freeze([
959
+ commandExists,
960
+ envInspect,
961
+ processList,
962
+ processOutput,
963
+ processStart,
964
+ processStatus,
965
+ processStop,
966
+ retry,
967
+ runCommand,
968
+ runPipeline,
969
+ waitForFile,
970
+ waitForOutput,
971
+ waitForPort,
972
+ ]);