wtagent 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { z } from "zod";
5
+ import { DEFAULT_LIMITS } from "../shared/limits.js";
6
+ import { resolveToolPath } from "../policy/path-guard.js";
7
+ import { ToolRegistry } from "./registry.js";
8
+ import { runProgram } from "./terminal-exec.js";
9
+
10
+ const xmlBoolean = z.preprocess((value) => {
11
+ if (value === "true") return true;
12
+ if (value === "false") return false;
13
+ return value;
14
+ }, z.boolean());
15
+
16
+ const xmlStringArray = z.preprocess((value) => {
17
+ if (Array.isArray(value)) {
18
+ return value;
19
+ }
20
+ if (typeof value !== "string") {
21
+ return value;
22
+ }
23
+
24
+ const trimmed = value.trim();
25
+ if (!trimmed) {
26
+ return [];
27
+ }
28
+ if (trimmed.startsWith("[")) {
29
+ try {
30
+ const parsed = JSON.parse(trimmed);
31
+ if (Array.isArray(parsed)) {
32
+ return parsed;
33
+ }
34
+ } catch {
35
+ // Fall through and treat the value as one argument.
36
+ }
37
+ }
38
+ return [value];
39
+ }, z.array(z.string()));
40
+
41
+ function requireAllowedPath(info, context) {
42
+ if (!info.inside && !context.allowOutside) {
43
+ throw new Error(`Path is outside project root: ${info.path}`);
44
+ }
45
+ return info.path;
46
+ }
47
+
48
+ async function resolveAllowedPath(rawPath, context) {
49
+ const info = await resolveToolPath(context.projectRoot, rawPath);
50
+ return requireAllowedPath(info, context);
51
+ }
52
+
53
+ function displayPath(projectRoot, targetPath) {
54
+ const relative = path.relative(projectRoot, targetPath);
55
+ return relative && !relative.startsWith("..") ? relative : targetPath;
56
+ }
57
+
58
+ // On Windows files commonly use CRLF line endings, while the model may
59
+ // supply old_text with LF endings (or vice versa). We try the exact text
60
+ // first, then the opposite line-ending convention so edits still match
61
+ // without silently rewriting every line ending in the file.
62
+ function matchOldText(content, oldText) {
63
+ if (content.includes(oldText)) {
64
+ return oldText;
65
+ }
66
+ const fileUsesCRLF = content.includes("\r\n");
67
+ if (fileUsesCRLF) {
68
+ const crlfVersion = oldText.replace(/\n/g, "\r\n");
69
+ if (content.includes(crlfVersion)) {
70
+ return crlfVersion;
71
+ }
72
+ } else {
73
+ const lfVersion = oldText.replace(/\r\n/g, "\n");
74
+ if (content.includes(lfVersion)) {
75
+ return lfVersion;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ async function writeTextAtomic(targetPath, content) {
82
+ const temporary = `${targetPath}.wtagent-${process.pid}.tmp`;
83
+ await fs.writeFile(temporary, content, "utf8");
84
+ try {
85
+ await fs.rename(temporary, targetPath);
86
+ } catch (error) {
87
+ if (process.platform !== "win32") {
88
+ throw error;
89
+ }
90
+ await fs.rm(targetPath, { force: true });
91
+ await fs.rename(temporary, targetPath);
92
+ } finally {
93
+ await fs.rm(temporary, { force: true }).catch(() => null);
94
+ }
95
+ }
96
+
97
+ async function listTree(root, target, options) {
98
+ const entries = [];
99
+ const excluded = new Set([".git", "node_modules", "dist", "build", "coverage"]);
100
+
101
+ async function walk(directory, depth) {
102
+ if (entries.length >= options.maxEntries) {
103
+ return;
104
+ }
105
+ const children = await fs.readdir(directory, { withFileTypes: true });
106
+ children.sort((a, b) => a.name.localeCompare(b.name));
107
+
108
+ for (const child of children) {
109
+ if (entries.length >= options.maxEntries) {
110
+ break;
111
+ }
112
+ if (!options.includeHidden && child.name.startsWith(".")) {
113
+ continue;
114
+ }
115
+ if (excluded.has(child.name)) {
116
+ continue;
117
+ }
118
+
119
+ const absolute = path.join(directory, child.name);
120
+ const relative = path.relative(root, absolute);
121
+ entries.push({
122
+ path: relative || ".",
123
+ type: child.isDirectory()
124
+ ? "directory"
125
+ : child.isSymbolicLink()
126
+ ? "symlink"
127
+ : "file",
128
+ });
129
+
130
+ if (child.isDirectory() && depth < options.depth) {
131
+ await walk(absolute, depth + 1);
132
+ }
133
+ }
134
+ }
135
+
136
+ await walk(target, 0);
137
+ return entries;
138
+ }
139
+
140
+ async function rgSearch({ query, searchPath, glob, regex, maxResults }) {
141
+ const argv = [
142
+ "--line-number",
143
+ "--no-heading",
144
+ "--color",
145
+ "never",
146
+ "--max-count",
147
+ String(maxResults),
148
+ ];
149
+ if (!regex) {
150
+ argv.push("--fixed-strings");
151
+ }
152
+ if (glob) {
153
+ argv.push("--glob", glob);
154
+ }
155
+ argv.push("--", query, searchPath);
156
+
157
+ return await new Promise((resolve, reject) => {
158
+ const child = spawn("rg", argv, {
159
+ shell: false,
160
+ windowsHide: true,
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ });
163
+ let stdout = "";
164
+ let stderr = "";
165
+ child.stdout.on("data", (chunk) => {
166
+ stdout += chunk.toString("utf8");
167
+ });
168
+ child.stderr.on("data", (chunk) => {
169
+ stderr += chunk.toString("utf8");
170
+ });
171
+ child.once("error", reject);
172
+ child.once("close", (code) => {
173
+ if (code === 0 || code === 1) {
174
+ resolve({ stdout, stderr, code });
175
+ } else {
176
+ reject(new Error(stderr || `rg exited with ${code}`));
177
+ }
178
+ });
179
+ });
180
+ }
181
+
182
+ async function fallbackSearch({ query, searchPath, maxResults }) {
183
+ const results = [];
184
+ const excluded = new Set([".git", "node_modules", "dist", "build", "coverage"]);
185
+
186
+ async function visit(current) {
187
+ if (results.length >= maxResults) return;
188
+ const stat = await fs.lstat(current);
189
+ if (stat.isSymbolicLink()) return;
190
+ if (stat.isDirectory()) {
191
+ if (excluded.has(path.basename(current))) return;
192
+ const children = await fs.readdir(current);
193
+ for (const child of children) {
194
+ await visit(path.join(current, child));
195
+ if (results.length >= maxResults) return;
196
+ }
197
+ return;
198
+ }
199
+ if (!stat.isFile() || stat.size > 1024 * 1024) return;
200
+
201
+ const content = await fs.readFile(current, "utf8").catch(() => null);
202
+ if (content == null) return;
203
+ for (const [index, line] of content.split(/\r?\n/).entries()) {
204
+ if (line.includes(query)) {
205
+ results.push(`${current}:${index + 1}:${line}`);
206
+ if (results.length >= maxResults) return;
207
+ }
208
+ }
209
+ }
210
+
211
+ await visit(searchPath);
212
+ return results.join("\n");
213
+ }
214
+
215
+ export function createDefaultToolRegistry({
216
+ processManager,
217
+ limits = DEFAULT_LIMITS,
218
+ } = {}) {
219
+ const registry = new ToolRegistry();
220
+
221
+ registry.register({
222
+ name: "fs.list",
223
+ description: "List files and directories in the project.",
224
+ inputDescription:
225
+ "<args><path>.</path><depth>2</depth><include_hidden>false</include_hidden></args>",
226
+ risk: "read",
227
+ inputSchema: z.object({
228
+ path: z.string().default("."),
229
+ depth: z.coerce.number().int().min(0).max(5).default(2),
230
+ include_hidden: xmlBoolean.default(false),
231
+ }),
232
+ execute: async (args, context) => {
233
+ const target = await resolveAllowedPath(args.path, context);
234
+ const entries = await listTree(
235
+ path.resolve(context.projectRoot),
236
+ target,
237
+ {
238
+ depth: args.depth,
239
+ includeHidden: args.include_hidden,
240
+ maxEntries: limits.maxDirectoryEntries,
241
+ },
242
+ );
243
+ return {
244
+ ok: true,
245
+ message: `Listed ${entries.length} entries.`,
246
+ data: {
247
+ entries,
248
+ truncated: entries.length >= limits.maxDirectoryEntries,
249
+ },
250
+ };
251
+ },
252
+ });
253
+
254
+ registry.register({
255
+ name: "fs.read",
256
+ description: "Read a text file in the project, optionally limiting the start byte and length.",
257
+ inputDescription:
258
+ "<args><path>src/main.js</path><offset>0</offset><max_bytes>131072</max_bytes></args>",
259
+ risk: "read",
260
+ inputSchema: z.object({
261
+ path: z.string().min(1),
262
+ offset: z.coerce.number().int().min(0).default(0),
263
+ max_bytes: z.coerce.number().int().min(1)
264
+ .max(1024 * 1024)
265
+ .default(limits.maxFileReadBytes),
266
+ }),
267
+ execute: async (args, context) => {
268
+ const target = await resolveAllowedPath(args.path, context);
269
+ const handle = await fs.open(target, "r");
270
+ try {
271
+ const stat = await handle.stat();
272
+ const buffer = Buffer.alloc(args.max_bytes);
273
+ const { bytesRead } = await handle.read(
274
+ buffer,
275
+ 0,
276
+ args.max_bytes,
277
+ args.offset,
278
+ );
279
+ return {
280
+ ok: true,
281
+ message: `Read ${displayPath(context.projectRoot, target)}.`,
282
+ data: {
283
+ content: buffer.subarray(0, bytesRead).toString("utf8"),
284
+ bytesRead,
285
+ nextOffset: args.offset + bytesRead,
286
+ truncated: args.offset + bytesRead < stat.size,
287
+ size: stat.size,
288
+ },
289
+ };
290
+ } finally {
291
+ await handle.close();
292
+ }
293
+ },
294
+ });
295
+
296
+ registry.register({
297
+ name: "fs.write",
298
+ description: "Create, overwrite, or append to a text file in the project.",
299
+ inputDescription:
300
+ "<args><path>src/main.js</path><content><![CDATA[...]]></content><mode>overwrite|append</mode></args>",
301
+ risk: "write",
302
+ inputSchema: z.object({
303
+ path: z.string().min(1),
304
+ content: z.string(),
305
+ mode: z.enum(["overwrite", "append"]).default("overwrite"),
306
+ }),
307
+ execute: async (args, context) => {
308
+ const target = await resolveAllowedPath(args.path, context);
309
+ await fs.mkdir(path.dirname(target), { recursive: true });
310
+ if (args.mode === "append") {
311
+ await fs.appendFile(target, args.content, "utf8");
312
+ } else {
313
+ await writeTextAtomic(target, args.content);
314
+ }
315
+ return {
316
+ ok: true,
317
+ message: `Wrote ${displayPath(context.projectRoot, target)}.`,
318
+ data: { bytes: Buffer.byteLength(args.content, "utf8") },
319
+ };
320
+ },
321
+ });
322
+
323
+ registry.register({
324
+ name: "fs.edit",
325
+ description: "Perform atomic exact text replacements on an existing file.",
326
+ inputDescription:
327
+ "<args><path>src/main.js</path><edits><item><old_text><![CDATA[...]]></old_text><new_text><![CDATA[...]]></new_text><replace_all>false</replace_all></item></edits></args>",
328
+ risk: "write",
329
+ inputSchema: z.object({
330
+ path: z.string().min(1),
331
+ edits: z.array(z.object({
332
+ old_text: z.string(),
333
+ new_text: z.string(),
334
+ replace_all: xmlBoolean.default(false),
335
+ })).min(1),
336
+ }),
337
+ execute: async (args, context) => {
338
+ const target = await resolveAllowedPath(args.path, context);
339
+ const original = await fs.readFile(target, "utf8");
340
+ let next = original;
341
+
342
+ for (const [index, edit] of args.edits.entries()) {
343
+ if (edit.old_text === edit.new_text) {
344
+ throw new Error(`Edit ${index + 1} does not change the file.`);
345
+ }
346
+ const matchedOld = matchOldText(next, edit.old_text);
347
+ if (matchedOld === null) {
348
+ throw new Error(`Edit ${index + 1} old_text was not found.`);
349
+ }
350
+ const occurrences = next.split(matchedOld).length - 1;
351
+ if (!edit.replace_all && occurrences !== 1) {
352
+ throw new Error(
353
+ `Edit ${index + 1} matched ${occurrences} times; make it unique or set replace_all.`,
354
+ );
355
+ }
356
+ next = edit.replace_all
357
+ ? next.split(matchedOld).join(edit.new_text)
358
+ : next.replace(matchedOld, edit.new_text);
359
+ }
360
+
361
+ await writeTextAtomic(target, next);
362
+ return {
363
+ ok: true,
364
+ message: `Edited ${displayPath(context.projectRoot, target)}.`,
365
+ data: { editsApplied: args.edits.length },
366
+ };
367
+ },
368
+ });
369
+
370
+ registry.register({
371
+ name: "fs.search",
372
+ description: "Search project files for text, preferring ripgrep.",
373
+ inputDescription:
374
+ "<args><query>search text</query><path>.</path><glob>*.js</glob><regex>false</regex><max_results>200</max_results></args>",
375
+ risk: "read",
376
+ inputSchema: z.object({
377
+ query: z.string().min(1),
378
+ path: z.string().default("."),
379
+ glob: z.string().optional(),
380
+ regex: xmlBoolean.default(false),
381
+ max_results: z.coerce.number().int().min(1)
382
+ .max(1000)
383
+ .default(limits.maxSearchResults),
384
+ }),
385
+ execute: async (args, context) => {
386
+ const target = await resolveAllowedPath(args.path, context);
387
+ let output;
388
+ let engine = "rg";
389
+ try {
390
+ const result = await rgSearch({
391
+ query: args.query,
392
+ searchPath: target,
393
+ glob: args.glob,
394
+ regex: args.regex,
395
+ maxResults: args.max_results,
396
+ });
397
+ output = result.stdout;
398
+ } catch (error) {
399
+ if (error.code !== "ENOENT") {
400
+ throw error;
401
+ }
402
+ engine = "javascript";
403
+ if (args.regex) {
404
+ throw new Error("Regex fallback requires ripgrep to be installed.");
405
+ }
406
+ output = await fallbackSearch({
407
+ query: args.query,
408
+ searchPath: target,
409
+ maxResults: args.max_results,
410
+ });
411
+ }
412
+ return {
413
+ ok: true,
414
+ message: output ? "Search completed." : "No matches.",
415
+ data: { engine, matches: output },
416
+ };
417
+ },
418
+ });
419
+
420
+ const executionSchema = z.object({
421
+ program: z.string().min(1),
422
+ argv: xmlStringArray.default([]),
423
+ cwd: z.string().default("."),
424
+ timeout_ms: z.coerce.number().int().min(100).max(30 * 60_000)
425
+ .default(limits.toolTimeoutMs),
426
+ inherit_sensitive_env: xmlBoolean.default(false),
427
+ });
428
+
429
+ registry.register({
430
+ name: "terminal.exec",
431
+ description: "Run a program that terminates. Use program + argv, not a shell string.",
432
+ inputDescription:
433
+ "<args><program>npm</program><argv><item>run</item><item>build</item></argv><cwd>.</cwd><timeout_ms>120000</timeout_ms></args>",
434
+ risk: "execute",
435
+ managesTimeout: true,
436
+ inputSchema: executionSchema,
437
+ execute: async (args, context) => {
438
+ const cwd = await resolveAllowedPath(args.cwd, context);
439
+ const timeoutMs = Math.min(args.timeout_ms, limits.toolTimeoutMs);
440
+ const result = await runProgram({
441
+ program: args.program,
442
+ argv: args.argv,
443
+ cwd,
444
+ timeoutMs,
445
+ maxOutputBytes: limits.maxToolOutputBytes,
446
+ inheritSensitiveEnv: args.inherit_sensitive_env,
447
+ onOutput: context.onToolOutput,
448
+ });
449
+ return {
450
+ ok: result.ok,
451
+ message: result.completionUnknown
452
+ ? (
453
+ `Command exceeded the ${timeoutMs}ms limit and did not exit `
454
+ + "within the termination grace period. Completion is unknown."
455
+ )
456
+ : result.timedOut
457
+ ? `Command timed out after ${timeoutMs}ms.`
458
+ : `Command exited with ${result.exitCode}.`,
459
+ stdout: result.stdout,
460
+ stderr: result.stderr || result.error?.message,
461
+ data: {
462
+ exitCode: result.exitCode,
463
+ signal: result.signal,
464
+ timedOut: result.timedOut,
465
+ truncated: result.truncated,
466
+ durationMs: result.durationMs,
467
+ },
468
+ meta: result.completionUnknown
469
+ ? {
470
+ timedOut: true,
471
+ completionUnknown: true,
472
+ recoverable: true,
473
+ }
474
+ : undefined,
475
+ };
476
+ },
477
+ });
478
+
479
+ if (processManager) {
480
+ registry.register({
481
+ name: "process.start",
482
+ description: "Start a long-running process such as a dev server; returns a process_id immediately.",
483
+ inputDescription:
484
+ "<args><program>npm</program><argv><item>run</item><item>dev</item></argv><cwd>.</cwd></args>",
485
+ risk: "execute",
486
+ inputSchema: z.object({
487
+ program: z.string().min(1),
488
+ argv: xmlStringArray.default([]),
489
+ cwd: z.string().default("."),
490
+ inherit_sensitive_env: xmlBoolean.default(false),
491
+ }),
492
+ execute: async (args, context) => {
493
+ const cwd = await resolveAllowedPath(args.cwd, context);
494
+ const snapshot = processManager.start({
495
+ program: args.program,
496
+ argv: args.argv,
497
+ cwd,
498
+ inheritSensitiveEnv: args.inherit_sensitive_env,
499
+ });
500
+ return {
501
+ ok: true,
502
+ message: `Started ${args.program} as ${snapshot.processId}.`,
503
+ data: snapshot,
504
+ };
505
+ },
506
+ });
507
+
508
+ registry.register({
509
+ name: "process.read",
510
+ description: "Read the status and logs of a long-running process started by this task.",
511
+ inputDescription: "<args><process_id>proc_1234</process_id></args>",
512
+ risk: "read",
513
+ inputSchema: z.object({ process_id: z.string().min(1) }),
514
+ execute: async (args) => {
515
+ const snapshot = processManager.read(args.process_id);
516
+ return {
517
+ ok: true,
518
+ message: `Read process ${args.process_id}.`,
519
+ data: snapshot,
520
+ };
521
+ },
522
+ });
523
+
524
+ registry.register({
525
+ name: "process.stop",
526
+ description: "Stop a long-running process started by this task.",
527
+ inputDescription: "<args><process_id>proc_1234</process_id></args>",
528
+ risk: "execute",
529
+ inputSchema: z.object({ process_id: z.string().min(1) }),
530
+ execute: async (args) => {
531
+ const snapshot = await processManager.stop(args.process_id);
532
+ return {
533
+ ok: true,
534
+ message: `Stopping process ${args.process_id}.`,
535
+ data: snapshot,
536
+ };
537
+ },
538
+ });
539
+
540
+ registry.register({
541
+ name: "process.list",
542
+ description: "List long-running processes started by this task.",
543
+ inputDescription: "<args></args>",
544
+ risk: "read",
545
+ inputSchema: z.object({}),
546
+ execute: async () => ({
547
+ ok: true,
548
+ message: "Listed managed processes.",
549
+ data: { processes: processManager.list() },
550
+ }),
551
+ });
552
+ }
553
+
554
+ return registry;
555
+ }
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { buildToolEnvironment } from "./safe-env.js";
4
+ import { killProcessTree } from "./process-utils.js";
5
+
6
+ const MAX_PROCESS_LOG_BYTES = 1024 * 1024;
7
+ const URL_PATTERN = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0)(?::\d+)?(?:\/[^\s]*)?/gi;
8
+
9
+ function appendBounded(record, stream, chunk) {
10
+ const text = chunk.toString("utf8");
11
+ record[stream] += text;
12
+ if (Buffer.byteLength(record[stream], "utf8") > MAX_PROCESS_LOG_BYTES) {
13
+ record[stream] = record[stream].slice(-MAX_PROCESS_LOG_BYTES);
14
+ record.truncated = true;
15
+ }
16
+
17
+ for (const match of text.matchAll(URL_PATTERN)) {
18
+ record.urls.add(match[0]);
19
+ }
20
+ }
21
+
22
+ export class ProcessManager {
23
+ #processes = new Map();
24
+
25
+ start({ program, argv, cwd, inheritSensitiveEnv }) {
26
+ const id = `proc_${randomUUID().slice(0, 8)}`;
27
+ const child = spawn(program, argv, {
28
+ cwd,
29
+ env: buildToolEnvironment({ inheritSensitive: inheritSensitiveEnv }),
30
+ shell: false,
31
+ detached: process.platform !== "win32",
32
+ windowsHide: false,
33
+ stdio: ["ignore", "pipe", "pipe"],
34
+ });
35
+
36
+ const record = {
37
+ id,
38
+ child,
39
+ program,
40
+ argv,
41
+ cwd,
42
+ startedAt: new Date().toISOString(),
43
+ status: "running",
44
+ exitCode: null,
45
+ signal: null,
46
+ stdout: "",
47
+ stderr: "",
48
+ truncated: false,
49
+ urls: new Set(),
50
+ };
51
+ this.#processes.set(id, record);
52
+
53
+ child.stdout.on("data", (chunk) => appendBounded(record, "stdout", chunk));
54
+ child.stderr.on("data", (chunk) => appendBounded(record, "stderr", chunk));
55
+ child.once("error", (error) => {
56
+ record.status = "error";
57
+ record.stderr += `\n${error.message}`;
58
+ });
59
+ child.once("close", (code, signal) => {
60
+ record.status = "exited";
61
+ record.exitCode = code;
62
+ record.signal = signal;
63
+ });
64
+
65
+ return this.snapshot(record);
66
+ }
67
+
68
+ read(id) {
69
+ const record = this.#processes.get(id);
70
+ if (!record) {
71
+ throw new Error(`Unknown process: ${id}`);
72
+ }
73
+ return this.snapshot(record);
74
+ }
75
+
76
+ list() {
77
+ return [...this.#processes.values()].map((record) => this.snapshot(record));
78
+ }
79
+
80
+ async stop(id) {
81
+ const record = this.#processes.get(id);
82
+ if (!record) {
83
+ throw new Error(`Unknown process: ${id}`);
84
+ }
85
+ if (record.status === "running") {
86
+ await killProcessTree(record.child.pid);
87
+ record.status = "stopping";
88
+ }
89
+ return this.snapshot(record);
90
+ }
91
+
92
+ async stopAll() {
93
+ await Promise.all(
94
+ [...this.#processes.keys()].map((id) => this.stop(id).catch(() => null)),
95
+ );
96
+ }
97
+
98
+ snapshot(record) {
99
+ return {
100
+ processId: record.id,
101
+ pid: record.child.pid,
102
+ program: record.program,
103
+ argv: record.argv,
104
+ cwd: record.cwd,
105
+ startedAt: record.startedAt,
106
+ status: record.status,
107
+ exitCode: record.exitCode,
108
+ signal: record.signal,
109
+ stdout: record.stdout,
110
+ stderr: record.stderr,
111
+ truncated: record.truncated,
112
+ detectedUrls: [...record.urls],
113
+ };
114
+ }
115
+ }
116
+
@@ -0,0 +1,31 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export async function killProcessTree(pid, signal = "SIGTERM") {
4
+ if (!pid) {
5
+ return;
6
+ }
7
+
8
+ if (process.platform === "win32") {
9
+ await new Promise((resolve) => {
10
+ const child = spawn(
11
+ "taskkill",
12
+ ["/pid", String(pid), "/T", "/F"],
13
+ { windowsHide: true, stdio: "ignore" },
14
+ );
15
+ child.once("close", resolve);
16
+ child.once("error", resolve);
17
+ });
18
+ return;
19
+ }
20
+
21
+ try {
22
+ process.kill(-pid, signal);
23
+ } catch {
24
+ try {
25
+ process.kill(pid, signal);
26
+ } catch {
27
+ // The process may already be gone.
28
+ }
29
+ }
30
+ }
31
+