jev-agent-tools 0.1.4 → 0.3.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 (180) hide show
  1. package/CHANGELOG.md +106 -1
  2. package/CONTRIBUTING.md +43 -0
  3. package/README.md +58 -17
  4. package/SECURITY.md +43 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +198 -0
  7. package/dist/adapters/ask-proof.js +200 -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 +234 -0
  11. package/dist/adapters/docs.js +192 -0
  12. package/dist/adapters/evidence-context.js +119 -0
  13. package/dist/adapters/exec.js +207 -0
  14. package/dist/adapters/files.js +418 -0
  15. package/dist/adapters/find.js +150 -0
  16. package/dist/adapters/git-base.js +32 -0
  17. package/dist/adapters/git-inventory.js +71 -0
  18. package/dist/adapters/git.js +483 -0
  19. package/dist/adapters/locate-file.js +197 -0
  20. package/dist/adapters/output-lines.js +46 -0
  21. package/dist/adapters/private-storage.js +106 -0
  22. package/dist/adapters/risk-callers.js +429 -0
  23. package/dist/adapters/runner-version.js +78 -0
  24. package/dist/adapters/shell.js +92 -0
  25. package/dist/adapters/syntax.js +187 -0
  26. package/dist/adapters/test-inventory.js +139 -0
  27. package/dist/adapters/usage.js +20 -0
  28. package/dist/adapters/utf8.js +47 -0
  29. package/dist/configuration.js +267 -0
  30. package/dist/constants.js +140 -0
  31. package/dist/core/ask-closure.js +282 -0
  32. package/dist/core/ask-proof.js +1 -0
  33. package/dist/core/ask-references.js +278 -0
  34. package/dist/core/asks.js +507 -0
  35. package/dist/core/batches.js +65 -0
  36. package/dist/core/command-output.js +224 -0
  37. package/dist/core/diff.js +178 -0
  38. package/dist/core/docs.js +302 -0
  39. package/dist/core/find.js +108 -0
  40. package/dist/core/git.js +1 -0
  41. package/dist/core/imports.js +550 -0
  42. package/dist/core/integrity.js +45 -0
  43. package/dist/core/lexical.js +132 -0
  44. package/dist/core/locate.js +169 -0
  45. package/dist/core/output.js +137 -0
  46. package/dist/core/pointer.js +29 -0
  47. package/dist/core/result-report.js +302 -0
  48. package/dist/core/risk-callers.js +851 -0
  49. package/dist/core/runner-version.js +45 -0
  50. package/dist/core/secret-path.js +34 -0
  51. package/dist/core/sections.js +230 -0
  52. package/dist/core/state.js +51 -0
  53. package/dist/core/syntax.js +1 -0
  54. package/dist/core/test-commands.js +334 -0
  55. package/dist/core/test-coverage.js +74 -0
  56. package/dist/core/test-discovery.js +1382 -0
  57. package/dist/core/test-evidence.js +527 -0
  58. package/dist/core/test-state.js +81 -0
  59. package/dist/core/truncate.js +12 -0
  60. package/dist/core/units.js +349 -0
  61. package/dist/describe.js +23 -0
  62. package/dist/guide.js +33 -0
  63. package/dist/host.js +24 -0
  64. package/dist/jev/client.js +456 -0
  65. package/dist/jev/pool.js +54 -0
  66. package/dist/jev/types.js +1 -0
  67. package/dist/mcp/main.js +124 -0
  68. package/dist/mcp/protocol.js +210 -0
  69. package/dist/mcp/tools.js +129 -0
  70. package/dist/presets/docs.js +62 -0
  71. package/dist/presets/risk.js +179 -0
  72. package/dist/presets/spec.js +81 -0
  73. package/dist/presets/witnesses.js +249 -0
  74. package/dist/render.js +114 -0
  75. package/dist/report-schema.js +1356 -0
  76. package/dist/result-types.js +1 -0
  77. package/dist/result.js +3 -0
  78. package/dist/runtime.js +1 -0
  79. package/dist/session.js +147 -0
  80. package/dist/texts/ask-files.js +3 -0
  81. package/dist/texts/ask.js +4 -0
  82. package/dist/texts/check-diff.js +20 -0
  83. package/dist/texts/configuration.js +1 -0
  84. package/dist/texts/find.js +19 -0
  85. package/dist/texts/guide.js +3 -0
  86. package/dist/texts/instructions.js +72 -0
  87. package/dist/texts/locate.js +15 -0
  88. package/dist/texts/select-tests.js +4 -0
  89. package/dist/tools/ask-files.js +450 -0
  90. package/dist/tools/ask-schema.js +70 -0
  91. package/dist/tools/ask.js +1147 -0
  92. package/dist/tools/check-diff.js +594 -0
  93. package/dist/tools/docs-check.js +408 -0
  94. package/dist/tools/find.js +682 -0
  95. package/dist/tools/locate.js +602 -0
  96. package/dist/tools/review-report.js +230 -0
  97. package/dist/tools/select-tests.js +821 -0
  98. package/dist/tools/spec-check.js +263 -0
  99. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  100. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  101. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  102. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  103. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  104. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  105. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  106. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  107. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  108. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  109. package/docs/agent-instructions.md +120 -0
  110. package/docs/design.md +16 -4
  111. package/docs/mcp.md +233 -0
  112. package/docs/tools/jev_ask.md +8 -5
  113. package/docs/tools/jev_ask_files.md +2 -1
  114. package/docs/tools/jev_check_diff.md +4 -1
  115. package/docs/tools/jev_find_files.md +2 -1
  116. package/docs/tools/jev_locate_in_file.md +5 -0
  117. package/docs/tools/jev_select_tests.md +4 -1
  118. package/package.json +19 -4
  119. package/rules/jev-ask.md +22 -1
  120. package/server.json +57 -0
  121. package/src/adapters/ask-files.ts +11 -3
  122. package/src/adapters/ask-proof.ts +69 -11
  123. package/src/adapters/canonical-path.ts +18 -0
  124. package/src/adapters/command.ts +102 -36
  125. package/src/adapters/docs.ts +33 -14
  126. package/src/adapters/evidence-context.ts +169 -0
  127. package/src/adapters/exec.ts +226 -0
  128. package/src/adapters/files.ts +146 -16
  129. package/src/adapters/find.ts +37 -7
  130. package/src/adapters/git-base.ts +7 -1
  131. package/src/adapters/git.ts +61 -8
  132. package/src/adapters/locate-file.ts +51 -9
  133. package/src/adapters/private-storage.ts +155 -0
  134. package/src/adapters/risk-callers.ts +7 -2
  135. package/src/adapters/shell.ts +113 -0
  136. package/src/adapters/test-inventory.ts +12 -4
  137. package/src/configuration.ts +55 -14
  138. package/src/constants.ts +37 -5
  139. package/src/core/ask-references.ts +262 -146
  140. package/src/core/asks.ts +79 -7
  141. package/src/core/command-output.ts +17 -1
  142. package/src/core/import-boundaries.ts +8 -3
  143. package/src/core/locate.ts +8 -5
  144. package/src/core/output.ts +34 -0
  145. package/src/core/result-report.ts +410 -0
  146. package/src/core/secret-path.ts +37 -0
  147. package/src/core/state.ts +8 -1
  148. package/src/core/units.ts +3 -2
  149. package/src/host.ts +11 -0
  150. package/src/index.ts +3 -0
  151. package/src/jev/client.ts +66 -16
  152. package/src/jev/types.ts +24 -3
  153. package/src/mcp/main.ts +135 -0
  154. package/src/mcp/protocol.ts +332 -0
  155. package/src/mcp/tools.ts +179 -0
  156. package/src/render.ts +109 -0
  157. package/src/report-schema.ts +1380 -0
  158. package/src/result-types.ts +234 -0
  159. package/src/result.ts +4 -1
  160. package/src/runtime.ts +6 -0
  161. package/src/session.ts +59 -0
  162. package/src/setup.ts +13 -5
  163. package/src/texts/ask-files.ts +4 -1
  164. package/src/texts/ask.ts +8 -1
  165. package/src/texts/check-diff.ts +7 -4
  166. package/src/texts/find.ts +8 -2
  167. package/src/texts/guide.ts +8 -16
  168. package/src/texts/instructions.ts +98 -0
  169. package/src/texts/locate.ts +8 -2
  170. package/src/texts/run-end.ts +2 -2
  171. package/src/texts/select-tests.ts +4 -1
  172. package/src/tools/ask-files.ts +311 -18
  173. package/src/tools/ask.ts +722 -95
  174. package/src/tools/check-diff.ts +337 -31
  175. package/src/tools/docs-check.ts +241 -38
  176. package/src/tools/find.ts +389 -29
  177. package/src/tools/locate.ts +387 -25
  178. package/src/tools/review-report.ts +308 -0
  179. package/src/tools/select-tests.ts +484 -23
  180. package/src/tools/spec-check.ts +194 -19
@@ -0,0 +1,192 @@
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
+ cause: signal?.aborted ? "cancelled" : "git_failure",
16
+ error: root.stderr.trim() || "Repository root not found.",
17
+ };
18
+ cwd = root.stdout.trim();
19
+ const list = await exec("git", ["ls-files", "-z"], {
20
+ cwd,
21
+ timeout: TIMEOUT_MS,
22
+ signal,
23
+ });
24
+ if (list.code || list.killed)
25
+ return {
26
+ ok: false,
27
+ cause: signal?.aborted ? "cancelled" : "git_failure",
28
+ error: list.stderr.trim() || "Unable to inventory tracked files.",
29
+ };
30
+ const tracked = new Set(list.stdout.split("\0").filter(Boolean));
31
+ const limits = [];
32
+ const cache = new Map();
33
+ const read = (path) => {
34
+ const previous = cache.get(path);
35
+ if (previous)
36
+ return previous;
37
+ const pending = (async () => {
38
+ if (!tracked.has(path) || path.split("/").includes(".git"))
39
+ return undefined;
40
+ if (signal?.aborted) {
41
+ limits.push({ path, reason: "collection interrupted" });
42
+ return undefined;
43
+ }
44
+ try {
45
+ const opened = await openRepoFile(cwd, path, {
46
+ exec,
47
+ signal,
48
+ inventory: tracked,
49
+ });
50
+ if (!opened.ok) {
51
+ limits.push({
52
+ path,
53
+ reason: opened.error,
54
+ ...(opened.cause ? { cause: opened.cause } : {}),
55
+ });
56
+ return undefined;
57
+ }
58
+ const handle = opened.handle;
59
+ try {
60
+ const stat = await handle.stat();
61
+ if (!stat.isFile() || stat.size > STATE_MAX_CHARS * 4) {
62
+ limits.push({
63
+ path,
64
+ reason: "not a regular file or file too large",
65
+ });
66
+ return undefined;
67
+ }
68
+ const buffer = Buffer.alloc(stat.size + 1);
69
+ let bytesRead = 0;
70
+ while (bytesRead < buffer.length) {
71
+ signal?.throwIfAborted();
72
+ const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, null);
73
+ if (!chunk.bytesRead)
74
+ break;
75
+ bytesRead += chunk.bytesRead;
76
+ }
77
+ if (bytesRead > stat.size) {
78
+ limits.push({ path, reason: "file changed during reading" });
79
+ return undefined;
80
+ }
81
+ const decoded = decodeUtf8(buffer.subarray(0, bytesRead));
82
+ if (!decoded.ok) {
83
+ limits.push({ path, reason: decoded.error });
84
+ return undefined;
85
+ }
86
+ const text = decoded.text;
87
+ if (text.includes("\0")) {
88
+ limits.push({ path, reason: "binary file" });
89
+ return undefined;
90
+ }
91
+ return { path, text };
92
+ }
93
+ finally {
94
+ await handle.close();
95
+ }
96
+ }
97
+ catch (error) {
98
+ limits.push({ path, reason: `unable to read: ${String(error)}` });
99
+ return undefined;
100
+ }
101
+ })();
102
+ cache.set(path, pending);
103
+ return pending;
104
+ };
105
+ const paths = [...tracked].filter((path) => /\.(?:md|mdx|markdown)$/i.test(path) ||
106
+ /(?:^|\/)(?:package|tsconfig[^/]*)\.json$/.test(path));
107
+ const files = [];
108
+ let index = 0;
109
+ await Promise.all(Array.from({ length: Math.min(8, paths.length) }, async () => {
110
+ for (;;) {
111
+ const path = paths[index++];
112
+ if (path === undefined)
113
+ return;
114
+ const source = await read(path);
115
+ if (source)
116
+ files.push(source);
117
+ }
118
+ }));
119
+ files.sort((a, b) => a.path.localeCompare(b.path));
120
+ return { ok: true, cwd, files, tracked, limits, read };
121
+ }
122
+ /** Admitted paths only; one batch search transports declaration lines to the pure core. */
123
+ export function docsDeclarationSearch(exec, inventory, signal) {
124
+ let admittedPaths;
125
+ return async (names) => {
126
+ if (!names.length)
127
+ return new Map();
128
+ admittedPaths ??= (async () => {
129
+ const candidates = [...inventory.tracked].filter((path) => /\.[cm]?[jt]sx?$|\.py$|\.go$/i.test(path) &&
130
+ !path.split("/").some((part) => part === ".." || part === ".git"));
131
+ const paths = [];
132
+ let index = 0;
133
+ await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, async () => {
134
+ for (;;) {
135
+ const path = candidates[index++];
136
+ if (path === undefined)
137
+ return;
138
+ const location = await resolveInsideRepo(inventory.cwd, path);
139
+ const admission = location.ok
140
+ ? await checkFileAdmission(inventory.cwd, location.rel, exec, signal, inventory.tracked)
141
+ : location;
142
+ if (admission.ok)
143
+ paths.push(path);
144
+ else if (admission.cause === "secret_pattern")
145
+ inventory.limits.push({
146
+ path,
147
+ reason: admission.error,
148
+ cause: admission.cause,
149
+ });
150
+ }
151
+ }));
152
+ return paths;
153
+ })();
154
+ const paths = await admittedPaths;
155
+ if (!paths.length)
156
+ return new Map();
157
+ const patterns = ["-e", docsDeclarationPattern(names)];
158
+ let result;
159
+ try {
160
+ result = await exec("rg", ["--json", ...patterns, "--", ...paths], {
161
+ cwd: inventory.cwd,
162
+ timeout: TIMEOUT_MS,
163
+ signal,
164
+ });
165
+ }
166
+ catch {
167
+ // A missing or unstartable rg is an unavailable search, not a failure.
168
+ signal?.throwIfAborted();
169
+ }
170
+ if (!result || result.killed || (result.code !== 0 && result.code !== 1)) {
171
+ inventory.limits.push({
172
+ path: "documentation",
173
+ reason: "declaration search unavailable",
174
+ });
175
+ return new Map(names.map((name) => [
176
+ name,
177
+ paths.length > 1 ? paths : [...paths, "<unknown declaration owner>"],
178
+ ]));
179
+ }
180
+ const admitted = new Set(paths);
181
+ const rows = [];
182
+ for (const line of result.stdout.split("\n")) {
183
+ if (!line)
184
+ continue;
185
+ const item = JSON.parse(line);
186
+ const path = item.data?.path?.text;
187
+ if (item.type === "match" && path && admitted.has(path))
188
+ rows.push({ path, text: item.data?.lines?.text ?? "" });
189
+ }
190
+ return attributeDocsDeclarations(names, rows);
191
+ };
192
+ }
@@ -0,0 +1,119 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { parse, resolve, sep } from "node:path";
3
+ import { TIMEOUT_MS } from "../constants.js";
4
+ /** Bind each judgment (including controls) to its admitted per-call provenance. */
5
+ export function withEvidenceContext(state, context) {
6
+ const provenance = {
7
+ ...context,
8
+ authority: { ...context.authority },
9
+ ...(context.effectiveRoot
10
+ ? { effectiveRoot: { ...context.effectiveRoot } }
11
+ : {}),
12
+ };
13
+ const evidence = state.evidence;
14
+ // A producer's non-object evidence is content, not a metadata container.
15
+ if (evidence !== undefined &&
16
+ (evidence === null ||
17
+ typeof evidence !== "object" ||
18
+ Array.isArray(evidence)))
19
+ return { ...state, evidenceContext: provenance };
20
+ return { ...state, evidence: { ...evidence, context: provenance } };
21
+ }
22
+ /** Metadata-only admission. No evidence, commands, caches or judgments precede this. */
23
+ export async function resolveEvidenceContext(initialCwd, root, deps) {
24
+ const canonical = deps.realpath ?? realpath;
25
+ const inspect = deps.lstat ?? lstat;
26
+ let initial;
27
+ try {
28
+ initial = await canonical(initialCwd);
29
+ }
30
+ catch {
31
+ return {
32
+ ok: false,
33
+ cause: "file_unavailable",
34
+ error: "Initial evidence authority cannot be canonicalized",
35
+ context: {
36
+ authority: { path: "", origin: deps.origin ?? "host" },
37
+ authorityReason: "Initial evidence authority cannot be canonicalized",
38
+ ...(root !== undefined ? { requestedRoot: root } : {}),
39
+ },
40
+ };
41
+ }
42
+ const origin = deps.origin ?? "host";
43
+ const context = {
44
+ authority: { path: initial, origin },
45
+ ...(root !== undefined ? { requestedRoot: root } : {}),
46
+ };
47
+ if (root === undefined) {
48
+ context.effectiveRoot = { path: initial, origin };
49
+ return { ok: true, cwd: initial, context };
50
+ }
51
+ const refuse = (reason, cause = "invalid_root") => ({
52
+ ok: false,
53
+ cause,
54
+ error: `Invalid root: ${reason}`,
55
+ context,
56
+ });
57
+ if (!root.trim())
58
+ return refuse("provide a non-empty directory");
59
+ if (root.split(/[\\/]/).includes(".."))
60
+ return refuse("parent traversal is forbidden");
61
+ const candidate = resolve(initial, root);
62
+ try {
63
+ let component = parse(candidate).root;
64
+ for (const part of candidate
65
+ .slice(component.length)
66
+ .split(sep)
67
+ .filter(Boolean)) {
68
+ component = resolve(component, part);
69
+ const metadata = await inspect(component);
70
+ if (metadata.isSymbolicLink())
71
+ return refuse("symbolic links are forbidden");
72
+ if (!metadata.isDirectory())
73
+ return refuse("root must be an existing directory");
74
+ }
75
+ const path = await canonical(candidate);
76
+ const git = async (cwd, args) => {
77
+ const result = await deps.exec("git", args, {
78
+ cwd,
79
+ timeout: TIMEOUT_MS,
80
+ signal: deps.signal,
81
+ });
82
+ if (result.code || result.killed)
83
+ throw new Error("Git metadata unavailable");
84
+ return args.includes("-z")
85
+ ? result.stdout
86
+ : result.stdout.replace(/\r?\n$/, "");
87
+ };
88
+ const initialTop = await canonical(await git(initial, ["rev-parse", "--show-toplevel"]));
89
+ const candidateTop = await canonical(await git(path, ["rev-parse", "--show-toplevel"]));
90
+ if (candidateTop !== path)
91
+ return refuse("root must equal a Git top-level directory");
92
+ const common = await canonical(resolve(initial, await git(initial, ["rev-parse", "--git-common-dir"])));
93
+ const candidateCommon = await canonical(resolve(path, await git(path, ["rev-parse", "--git-common-dir"])));
94
+ if (candidateCommon !== common)
95
+ return refuse("root belongs to a different repository");
96
+ const listed = await git(initial, [
97
+ "worktree",
98
+ "list",
99
+ "--porcelain",
100
+ "-z",
101
+ ]);
102
+ const registered = listed.split("\0\0").some((block) => {
103
+ const fields = block.split("\0");
104
+ const worktree = fields
105
+ .find((field) => field.startsWith("worktree "))
106
+ ?.slice(9);
107
+ return (worktree !== undefined &&
108
+ resolve(worktree) === path &&
109
+ !fields.some((field) => /^prunable(?: |$)/.test(field)));
110
+ });
111
+ if (path !== initialTop && !registered)
112
+ return refuse("root is not a registered live worktree");
113
+ context.effectiveRoot = { path, origin: "override", commonDir: common };
114
+ return { ok: true, cwd: path, context };
115
+ }
116
+ catch (error) {
117
+ return refuse(`cannot admit directory (${error instanceof Error ? error.message : String(error)})`, deps.signal?.aborted ? "cancelled" : "invalid_root");
118
+ }
119
+ }
@@ -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
+ }