jev-agent-tools 0.1.3 → 0.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 (135) hide show
  1. package/CHANGELOG.md +88 -3
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +64 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +294 -0
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host-tui.d.ts +14 -0
  117. package/src/host.ts +11 -0
  118. package/src/index.ts +13 -5
  119. package/src/jev/client.ts +12 -0
  120. package/src/jev/types.ts +6 -0
  121. package/src/mcp/main.ts +135 -0
  122. package/src/mcp/protocol.ts +282 -0
  123. package/src/mcp/tools.ts +166 -0
  124. package/src/secret-input.ts +222 -0
  125. package/src/session.ts +59 -0
  126. package/src/setup.ts +170 -0
  127. package/src/texts/configuration.ts +1 -1
  128. package/src/tools/ask-files.ts +8 -13
  129. package/src/tools/ask.ts +29 -28
  130. package/src/tools/check-diff.ts +11 -11
  131. package/src/tools/docs-check.ts +1 -0
  132. package/src/tools/find.ts +8 -8
  133. package/src/tools/locate.ts +8 -9
  134. package/src/tools/select-tests.ts +10 -10
  135. package/src/tools/spec-check.ts +1 -0
@@ -0,0 +1,181 @@
1
+ import { chmod, mkdtemp, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ASK_TIMEOUT_S, OUTPUT_CHUNK_CHARS, OUTPUT_FAILURE_WINDOW_LINES, OUTPUT_FILE_MAX_BYTES, OUTPUT_FIND_MAX_CALLS, OUTPUT_LINE_MAX_CHARS, OUTPUT_REPEAT_MIN, OUTPUT_SHAPE_MAX_COUNT, } from "../constants.js";
5
+ import { cleanOutput, createRarityCompressor, failureTargets, lineShape, } from "../core/command-output.js";
6
+ import { outputLines } from "./output-lines.js";
7
+ import { resolveShell } from "./shell.js";
8
+ export async function captureCommand(exec, cwd, command, timeoutS = ASK_TIMEOUT_S, signal) {
9
+ const directory = await mkdtemp(join(tmpdir(), "jev-output-"));
10
+ try {
11
+ await chmod(directory, 0o700);
12
+ const stdoutPath = join(directory, "stdout");
13
+ const stderrPath = join(directory, "stderr");
14
+ signal?.throwIfAborted();
15
+ let executed;
16
+ try {
17
+ const shell = resolveShell();
18
+ // Fail closed: never spawn a bare name that PATH could resolve to WSL.
19
+ if (!shell.ok)
20
+ throw new Error(shell.error);
21
+ executed = await exec(shell.executable, [
22
+ ...shell.prefix,
23
+ "-c",
24
+ `${shell.scriptPrefix}exec >"$1" 2>"$2"; set --; (\n${command}\n)\nexit $?`,
25
+ "jev",
26
+ stdoutPath,
27
+ stderrPath,
28
+ ], { cwd, timeout: timeoutS * 1000, signal });
29
+ }
30
+ catch (error) {
31
+ signal?.throwIfAborted();
32
+ return {
33
+ ok: true,
34
+ output: {
35
+ command,
36
+ exit_code: null,
37
+ timed_out: false,
38
+ stdout: "",
39
+ stderr: `Command executable unavailable: ${String(error)}`,
40
+ compressed: true,
41
+ truncated: false,
42
+ },
43
+ originalBytes: 0,
44
+ compressedChars: 0,
45
+ lineOmittedChars: 0,
46
+ shapeLimitExceeded: false,
47
+ selectedPassages: false,
48
+ assertion: false,
49
+ targets: [],
50
+ };
51
+ }
52
+ signal?.throwIfAborted();
53
+ const sizes = await Promise.all([stdoutPath, stderrPath].map(async (path) => {
54
+ try {
55
+ return (await stat(path)).size;
56
+ }
57
+ catch (error) {
58
+ if (error instanceof Error &&
59
+ "code" in error &&
60
+ error.code === "ENOENT")
61
+ return 0;
62
+ throw error;
63
+ }
64
+ }));
65
+ if (sizes.some((size) => size > OUTPUT_FILE_MAX_BYTES))
66
+ return {
67
+ ok: false,
68
+ error: `output exceeded ${OUTPUT_FILE_MAX_BYTES} bytes per stream; narrow command`,
69
+ };
70
+ const targets = [];
71
+ let signature;
72
+ let compressedChars = 0;
73
+ let linesTruncated = false;
74
+ let lineOmittedChars = 0;
75
+ let shapeLimitExceeded = false;
76
+ const texts = [];
77
+ for (const [index, path] of [stdoutPath, stderrPath].entries()) {
78
+ if (!sizes[index]) {
79
+ texts.push(index === 0 ? executed.stdout : executed.stderr);
80
+ continue;
81
+ }
82
+ const frequencies = new Map();
83
+ let shapeLimit = false;
84
+ for await (const raw of outputLines(path, signal)) {
85
+ signal?.throwIfAborted();
86
+ const line = cleanOutput(raw);
87
+ linesTruncated ||= raw.endsWith(`…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`);
88
+ const shape = lineShape(line);
89
+ if (!frequencies.has(shape) &&
90
+ frequencies.size >= OUTPUT_SHAPE_MAX_COUNT) {
91
+ shapeLimit = true;
92
+ shapeLimitExceeded = true;
93
+ frequencies.clear();
94
+ break;
95
+ }
96
+ frequencies.set(shape, Math.min(OUTPUT_REPEAT_MIN, (frequencies.get(shape) ?? 0) + 1));
97
+ }
98
+ let text = "";
99
+ const compressor = createRarityCompressor(frequencies, (line) => {
100
+ compressedChars += line.length + 1;
101
+ const remaining = OUTPUT_CHUNK_CHARS * OUTPUT_FIND_MAX_CALLS + 1 - text.length;
102
+ if (remaining > 0)
103
+ text += `${line}\n`.slice(0, remaining);
104
+ });
105
+ const failureWindow = [];
106
+ let failureSeen = false;
107
+ let afterFailure = 0;
108
+ // node:test prints its failing-test block ("✖ failing tests:" then
109
+ // "test at <file>:<line>:<col>") after one summary line per test, so
110
+ // the block can sit more than one window past the first anchor. Once
111
+ // the first window closes, keep scanning for that header and collect
112
+ // the next window after it. At most two windows (2 * (WINDOW + 1)
113
+ // lines) are ever buffered per stream.
114
+ let secondSeen = false;
115
+ let afterSecond = 0;
116
+ const isAnchor = (line) => failureTargets(line).signature !== undefined ||
117
+ /^\s*(?:✖|ERROR |FAIL |test at )/.test(line);
118
+ const isFailingTestsHeader = (line) => /^\s*(?:✖\s+)?failing tests:?\s*$/.test(line);
119
+ if (shapeLimit)
120
+ compressor.line(`…[shape limit exceeded at ${OUTPUT_SHAPE_MAX_COUNT}; rarity grouping disabled]`);
121
+ for await (const raw of outputLines(path, signal, (chars) => {
122
+ lineOmittedChars += chars;
123
+ })) {
124
+ signal?.throwIfAborted();
125
+ linesTruncated ||= raw.endsWith(`…[line truncated at ${OUTPUT_LINE_MAX_CHARS} chars]`);
126
+ compressor.line(cleanOutput(raw));
127
+ if (!failureSeen) {
128
+ failureWindow.push(cleanOutput(raw));
129
+ if (failureWindow.length > OUTPUT_FAILURE_WINDOW_LINES + 1)
130
+ failureWindow.shift();
131
+ if (isAnchor(raw))
132
+ failureSeen = true;
133
+ }
134
+ else if (afterFailure++ < OUTPUT_FAILURE_WINDOW_LINES) {
135
+ failureWindow.push(cleanOutput(raw));
136
+ if (!secondSeen && afterFailure > 1 && isFailingTestsHeader(raw)) {
137
+ secondSeen = true;
138
+ afterSecond = 0;
139
+ }
140
+ }
141
+ else if (!secondSeen) {
142
+ if (isFailingTestsHeader(raw)) {
143
+ secondSeen = true;
144
+ afterSecond = 0;
145
+ failureWindow.push(cleanOutput(raw));
146
+ }
147
+ }
148
+ else if (afterSecond++ < OUTPUT_FAILURE_WINDOW_LINES) {
149
+ failureWindow.push(cleanOutput(raw));
150
+ }
151
+ }
152
+ compressor.finish();
153
+ const failure = failureTargets(failureWindow.join("\n"));
154
+ signature ??= failure.signature;
155
+ targets.push(...failure.targets.slice(0, 20 - targets.length));
156
+ texts.push(text);
157
+ }
158
+ return {
159
+ ok: true,
160
+ output: {
161
+ command,
162
+ exit_code: executed.killed ? null : executed.code,
163
+ timed_out: executed.killed,
164
+ stdout: texts[0] ?? "",
165
+ stderr: texts[1] ?? "",
166
+ compressed: true,
167
+ truncated: linesTruncated,
168
+ },
169
+ originalBytes: sizes.reduce((sum, size) => sum + size, 0),
170
+ compressedChars,
171
+ lineOmittedChars,
172
+ shapeLimitExceeded,
173
+ selectedPassages: false,
174
+ assertion: signature === "assertion",
175
+ targets,
176
+ };
177
+ }
178
+ finally {
179
+ await rm(directory, { recursive: true, force: true });
180
+ }
181
+ }
@@ -0,0 +1,172 @@
1
+ import { STATE_MAX_CHARS, TIMEOUT_MS } from "../constants.js";
2
+ import { attributeDocsDeclarations, docsDeclarationPattern, } from "../core/docs.js";
3
+ import { checkFileAdmission, openRepoFile, resolveInsideRepo, } from "./files.js";
4
+ import { decodeUtf8 } from "./utf8.js";
5
+ /** Inventory paths eagerly, but load only Markdown/configuration and subsequently reached sources. */
6
+ export async function collectDocsInventory(exec, cwd, signal) {
7
+ const root = await exec("git", ["rev-parse", "--show-toplevel"], {
8
+ cwd,
9
+ timeout: TIMEOUT_MS,
10
+ signal,
11
+ });
12
+ if (root.code || root.killed)
13
+ return {
14
+ ok: false,
15
+ error: root.stderr.trim() || "Repository root not found.",
16
+ };
17
+ cwd = root.stdout.trim();
18
+ const list = await exec("git", ["ls-files", "-z"], {
19
+ cwd,
20
+ timeout: TIMEOUT_MS,
21
+ signal,
22
+ });
23
+ if (list.code || list.killed)
24
+ return {
25
+ ok: false,
26
+ error: list.stderr.trim() || "Unable to inventory tracked files.",
27
+ };
28
+ const tracked = new Set(list.stdout.split("\0").filter(Boolean));
29
+ const limits = [];
30
+ const cache = new Map();
31
+ const read = (path) => {
32
+ const previous = cache.get(path);
33
+ if (previous)
34
+ return previous;
35
+ const pending = (async () => {
36
+ if (!tracked.has(path) || path.split("/").includes(".git"))
37
+ return undefined;
38
+ if (signal?.aborted) {
39
+ limits.push({ path, reason: "collection interrupted" });
40
+ return undefined;
41
+ }
42
+ try {
43
+ const opened = await openRepoFile(cwd, path, {
44
+ exec,
45
+ signal,
46
+ inventory: tracked,
47
+ });
48
+ if (!opened.ok) {
49
+ limits.push({ path, reason: opened.error });
50
+ return undefined;
51
+ }
52
+ const handle = opened.handle;
53
+ try {
54
+ const stat = await handle.stat();
55
+ if (!stat.isFile() || stat.size > STATE_MAX_CHARS * 4) {
56
+ limits.push({
57
+ path,
58
+ reason: "not a regular file or file too large",
59
+ });
60
+ return undefined;
61
+ }
62
+ const buffer = Buffer.alloc(stat.size + 1);
63
+ let bytesRead = 0;
64
+ while (bytesRead < buffer.length) {
65
+ signal?.throwIfAborted();
66
+ const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, null);
67
+ if (!chunk.bytesRead)
68
+ break;
69
+ bytesRead += chunk.bytesRead;
70
+ }
71
+ if (bytesRead > stat.size) {
72
+ limits.push({ path, reason: "file changed during reading" });
73
+ return undefined;
74
+ }
75
+ const decoded = decodeUtf8(buffer.subarray(0, bytesRead));
76
+ if (!decoded.ok) {
77
+ limits.push({ path, reason: decoded.error });
78
+ return undefined;
79
+ }
80
+ const text = decoded.text;
81
+ if (text.includes("\0")) {
82
+ limits.push({ path, reason: "binary file" });
83
+ return undefined;
84
+ }
85
+ return { path, text };
86
+ }
87
+ finally {
88
+ await handle.close();
89
+ }
90
+ }
91
+ catch (error) {
92
+ limits.push({ path, reason: `unable to read: ${String(error)}` });
93
+ return undefined;
94
+ }
95
+ })();
96
+ cache.set(path, pending);
97
+ return pending;
98
+ };
99
+ const paths = [...tracked].filter((path) => /\.(?:md|mdx|markdown)$/i.test(path) ||
100
+ /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path));
101
+ const files = [];
102
+ let index = 0;
103
+ await Promise.all(Array.from({ length: Math.min(8, paths.length) }, async () => {
104
+ for (;;) {
105
+ const path = paths[index++];
106
+ if (path === undefined)
107
+ return;
108
+ const source = await read(path);
109
+ if (source)
110
+ files.push(source);
111
+ }
112
+ }));
113
+ files.sort((a, b) => a.path.localeCompare(b.path));
114
+ return { ok: true, cwd, files, tracked, limits, read };
115
+ }
116
+ /** Admitted paths only; one batch search transports declaration lines to the pure core. */
117
+ export function docsDeclarationSearch(exec, inventory, signal) {
118
+ let admittedPaths;
119
+ return async (names) => {
120
+ if (!names.length)
121
+ return new Map();
122
+ admittedPaths ??= (async () => {
123
+ const candidates = [...inventory.tracked].filter((path) => /\.[cm]?[jt]sx?$|\.py$|\.go$/i.test(path) &&
124
+ !path.split("/").some((part) => part === ".." || part === ".git"));
125
+ const paths = [];
126
+ let index = 0;
127
+ await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, async () => {
128
+ for (;;) {
129
+ const path = candidates[index++];
130
+ if (path === undefined)
131
+ return;
132
+ const location = await resolveInsideRepo(inventory.cwd, path);
133
+ const admitted = location.ok &&
134
+ (await checkFileAdmission(inventory.cwd, location.rel, exec, signal, inventory.tracked)).ok;
135
+ if (admitted)
136
+ paths.push(path);
137
+ }
138
+ }));
139
+ return paths;
140
+ })();
141
+ const paths = await admittedPaths;
142
+ if (!paths.length)
143
+ return new Map();
144
+ const patterns = ["-e", docsDeclarationPattern(names)];
145
+ const result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
146
+ cwd: inventory.cwd,
147
+ timeout: TIMEOUT_MS,
148
+ signal,
149
+ });
150
+ if (result.killed || (result.code !== 0 && result.code !== 1)) {
151
+ inventory.limits.push({
152
+ path: "documentation",
153
+ reason: "declaration search unavailable",
154
+ });
155
+ return new Map(names.map((name) => [
156
+ name,
157
+ paths.length > 1 ? paths : [...paths, "<unknown declaration owner>"],
158
+ ]));
159
+ }
160
+ const admitted = new Set(paths);
161
+ const rows = [];
162
+ for (const line of result.stdout.split("\n")) {
163
+ if (!line)
164
+ continue;
165
+ const item = JSON.parse(line);
166
+ const path = item.data?.path?.text;
167
+ if (item.type === "match" && path && admitted.has(path))
168
+ rows.push({ path, text: item.data?.lines?.text ?? "" });
169
+ }
170
+ return attributeDocsDeclarations(names, rows);
171
+ };
172
+ }
@@ -0,0 +1,207 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { PROCESS_KILL_GRACE_MS, PROCESS_PIPE_GRACE_MS, PROCESS_TREE_TIMEOUT_MS, } from "../constants.js";
5
+ /** Signal the managed process group, or await Windows process-tree termination. */
6
+ export async function killTree(child, signal = "SIGTERM", platform = process.platform) {
7
+ const pid = child.pid;
8
+ if (pid === undefined)
9
+ return;
10
+ if (platform === "win32") {
11
+ const taskkill = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe");
12
+ const pids = [pid];
13
+ // MSYS fork/exec can leave Windows parent IDs pointing at vanished helper
14
+ // processes. Use the same Bash installation's POSIX table before killing
15
+ // the root; taskkill /T alone cannot find those ordinary descendants.
16
+ if (basename(child.spawnfile).toLowerCase() === "bash.exe") {
17
+ const directory = dirname(child.spawnfile);
18
+ const ps = [
19
+ join(directory, "ps.exe"),
20
+ join(directory, "..", "usr", "bin", "ps.exe"),
21
+ ].find(existsSync);
22
+ if (ps) {
23
+ const snapshot = Promise.withResolvers();
24
+ execFile(ps, ["-e"], { windowsHide: true, timeout: PROCESS_TREE_TIMEOUT_MS }, (error, stdout) => snapshot.resolve(error ? "" : stdout));
25
+ const rows = [];
26
+ for (const line of (await snapshot.promise).split("\n")) {
27
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(line);
28
+ if (!match)
29
+ continue;
30
+ rows.push({
31
+ pid: Number(match[1]),
32
+ parent: Number(match[2]),
33
+ windows: Number(match[4]),
34
+ });
35
+ }
36
+ const root = rows.find((row) => row.windows === pid);
37
+ if (root) {
38
+ const managed = new Set([root.pid]);
39
+ let added = true;
40
+ while (added) {
41
+ added = false;
42
+ for (const row of rows) {
43
+ if (!managed.has(row.pid) && managed.has(row.parent)) {
44
+ managed.add(row.pid);
45
+ if (row.windows > 0)
46
+ pids.push(row.windows);
47
+ added = true;
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+ }
54
+ const { promise, resolve } = Promise.withResolvers();
55
+ execFile(taskkill, [...new Set(pids)]
56
+ .flatMap((target) => ["/PID", String(target)])
57
+ .concat("/T", "/F"), { windowsHide: true, timeout: PROCESS_TREE_TIMEOUT_MS }, (error) => {
58
+ if (error)
59
+ child.kill();
60
+ resolve();
61
+ });
62
+ await promise;
63
+ return;
64
+ }
65
+ try {
66
+ process.kill(-pid, signal);
67
+ }
68
+ catch {
69
+ child.kill(signal);
70
+ }
71
+ return;
72
+ }
73
+ function groupExists(child) {
74
+ if (child.pid === undefined)
75
+ return false;
76
+ try {
77
+ process.kill(-child.pid, 0);
78
+ return true;
79
+ }
80
+ catch (error) {
81
+ return !(error instanceof Error &&
82
+ "code" in error &&
83
+ error.code === "ESRCH");
84
+ }
85
+ }
86
+ /**
87
+ * Capture a process without a shell. Timeout and abort finish tree termination
88
+ * before resolving, even when the direct child exits before its descendants.
89
+ * Deliberately detached descendants are not sandboxed or tracked.
90
+ */
91
+ export const spawnExec = (command, args, options) => {
92
+ const { promise, resolve, reject } = Promise.withResolvers();
93
+ const posix = process.platform !== "win32";
94
+ // Git's bin/bash.exe forwards to usr/bin/bash.exe. Spawn the MSYS process
95
+ // directly so its Windows PID can be matched in the POSIX process table.
96
+ if (!posix && basename(command).toLowerCase() === "bash.exe") {
97
+ const direct = join(dirname(command), "..", "usr", "bin", "bash.exe");
98
+ if (existsSync(direct))
99
+ command = direct;
100
+ }
101
+ const child = spawn(command, args, {
102
+ cwd: options.cwd,
103
+ shell: false,
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ windowsHide: true,
106
+ detached: posix,
107
+ });
108
+ let stdout = "";
109
+ let stderr = "";
110
+ let killed = false;
111
+ let spawned = false;
112
+ let settled = false;
113
+ let finishing = false;
114
+ let force;
115
+ let pipes;
116
+ let termination;
117
+ let terminated;
118
+ child.stdout.setEncoding("utf8");
119
+ child.stderr.setEncoding("utf8");
120
+ child.stdout.on("data", (chunk) => {
121
+ stdout += chunk;
122
+ });
123
+ child.stderr.on("data", (chunk) => {
124
+ stderr += chunk;
125
+ });
126
+ const kill = () => {
127
+ if (killed || settled)
128
+ return;
129
+ killed = true;
130
+ if (posix) {
131
+ void killTree(child, "SIGTERM");
132
+ const completion = Promise.withResolvers();
133
+ termination = completion.promise;
134
+ terminated = completion.resolve;
135
+ // Keep this timer referenced: a closing transport must not exit
136
+ // before a surviving member of the managed group receives SIGKILL.
137
+ force = setTimeout(() => {
138
+ void killTree(child, "SIGKILL");
139
+ completion.resolve();
140
+ }, PROCESS_KILL_GRACE_MS);
141
+ }
142
+ else
143
+ termination = killTree(child);
144
+ };
145
+ const timer = options.timeout > 0 ? setTimeout(kill, options.timeout) : undefined;
146
+ if (options.signal?.aborted)
147
+ kill();
148
+ else
149
+ options.signal?.addEventListener("abort", kill, { once: true });
150
+ const cleanup = () => {
151
+ clearTimeout(timer);
152
+ clearTimeout(force);
153
+ clearTimeout(pipes);
154
+ options.signal?.removeEventListener("abort", kill);
155
+ };
156
+ const finish = async (code, signal) => {
157
+ if (settled || finishing)
158
+ return;
159
+ finishing = true;
160
+ clearTimeout(pipes);
161
+ if (posix && terminated && !groupExists(child)) {
162
+ clearTimeout(force);
163
+ terminated();
164
+ }
165
+ if (termination)
166
+ await termination;
167
+ settled = true;
168
+ cleanup();
169
+ resolve({
170
+ stdout,
171
+ stderr,
172
+ code: code ?? (signal ? 128 + signalNumber(signal) : 1),
173
+ killed,
174
+ });
175
+ };
176
+ child.on("spawn", () => {
177
+ spawned = true;
178
+ });
179
+ child.on("error", (error) => {
180
+ if (spawned)
181
+ return;
182
+ settled = true;
183
+ cleanup();
184
+ reject(error);
185
+ });
186
+ child.on("exit", (code, signal) => {
187
+ // An escaped descendant may hold inherited pipes open indefinitely.
188
+ pipes = setTimeout(() => {
189
+ child.stdout.destroy();
190
+ child.stderr.destroy();
191
+ void finish(code, signal);
192
+ }, PROCESS_PIPE_GRACE_MS);
193
+ });
194
+ child.on("close", (code, signal) => {
195
+ void finish(code, signal);
196
+ });
197
+ return promise;
198
+ };
199
+ function signalNumber(signal) {
200
+ const numbers = {
201
+ SIGHUP: 1,
202
+ SIGINT: 2,
203
+ SIGKILL: 9,
204
+ SIGTERM: 15,
205
+ };
206
+ return numbers[signal] ?? 0;
207
+ }