@oddessentials/agent-guild 0.33.1 → 0.34.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,289 @@
1
+ // Project pin files only. A pin is what a file asks for, never proof that a
2
+ // runtime is installed or which binary a shell would execute.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export const PIN_LIMIT = 64 * 1024;
8
+ export const PIN_TEXT_MAX = 128;
9
+
10
+ export const PIN_NAMES = [
11
+ '.nvmrc', '.node-version', '.python-version', 'package.json', 'pyproject.toml',
12
+ 'rust-toolchain.toml', 'rust-toolchain', 'go.mod', 'global.json', '.tool-versions',
13
+ ];
14
+
15
+ const TOOL_VERSIONS = {
16
+ node: ['tool-versions-node', 'Node.js'],
17
+ nodejs: ['tool-versions-node', 'Node.js'],
18
+ python: ['tool-versions-python', 'Python'],
19
+ go: ['tool-versions-go', 'Go'],
20
+ golang: ['tool-versions-go', 'Go'],
21
+ rust: ['tool-versions-rust', 'Rust'],
22
+ dotnet: ['tool-versions-dotnet', '.NET SDK'],
23
+ 'dotnet-sdk': ['tool-versions-dotnet', '.NET SDK'],
24
+ };
25
+
26
+ function pin(id, label, source, status, version = null, detail = null) {
27
+ return { id, label, source, version, status, detail };
28
+ }
29
+
30
+ function configured(id, label, source, version) {
31
+ const text = typeof version === 'string' ? version.trim() : '';
32
+ if (!text) return pin(id, label, source, 'invalid', null, 'The pin is empty.');
33
+ if (text.length > PIN_TEXT_MAX) return pin(id, label, source, 'invalid', null, 'The pin is longer than 128 characters.');
34
+ return pin(id, label, source, 'configured', text);
35
+ }
36
+
37
+ function stripBom(text) {
38
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
39
+ }
40
+
41
+ function versionLine(text) {
42
+ for (const raw of stripBom(text).split(/\r?\n/)) {
43
+ const line = raw.trim();
44
+ if (!line || line.startsWith('#')) continue;
45
+ return line;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function textPin(id, label, source, text) {
51
+ const line = versionLine(text);
52
+ if (line === null) return [pin(id, label, source, 'invalid', null, 'The pin file has no version.')];
53
+ return [configured(id, label, source, line)];
54
+ }
55
+
56
+ function packagePins(text) {
57
+ let data;
58
+ try { data = JSON.parse(stripBom(text)); } catch {
59
+ return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
60
+ }
61
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
62
+ return [pin('package-json', 'package.json', 'package.json', 'invalid', null, 'package.json could not be read as JSON.')];
63
+ }
64
+ const pins = [];
65
+ if (data.engines !== undefined) {
66
+ if (!data.engines || typeof data.engines !== 'object' || Array.isArray(data.engines)) {
67
+ pins.push(pin('engines', 'package.json', 'package.json', 'invalid', null, 'engines is not an object.'));
68
+ } else {
69
+ for (const [key, id, label] of [['node', 'engines-node', 'Node.js'], ['npm', 'engines-npm', 'npm']]) {
70
+ if (data.engines[key] === undefined) continue;
71
+ if (typeof data.engines[key] !== 'string') pins.push(pin(id, label, 'package.json', 'invalid', null, `engines.${key} is not a string.`));
72
+ else pins.push(configured(id, label, 'package.json', data.engines[key]));
73
+ }
74
+ }
75
+ }
76
+ if (data.packageManager !== undefined) {
77
+ if (typeof data.packageManager !== 'string') pins.push(pin('package-manager', 'packageManager', 'package.json', 'invalid', null, 'packageManager is not a string.'));
78
+ else {
79
+ const name = data.packageManager.split('@')[0].trim();
80
+ pins.push(configured('package-manager', name || 'packageManager', 'package.json', data.packageManager));
81
+ }
82
+ }
83
+ return pins;
84
+ }
85
+
86
+ function pythonRequires(text) {
87
+ const lines = stripBom(text).split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
88
+ const keys = lines.filter((line) => /^requires-python\s*=/.test(line));
89
+ if (!keys.length) return [];
90
+ if (keys.length !== 1) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
91
+ const match = /^requires-python\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(keys[0]);
92
+ if (!match) return [pin('requires-python', 'Python', 'pyproject.toml', 'invalid', null, 'requires-python is not a single-line string.')];
93
+ return [configured('requires-python', 'Python', 'pyproject.toml', match[2])];
94
+ }
95
+
96
+ function rustToml(text) {
97
+ let section = '';
98
+ let channel = null;
99
+ let saw = false;
100
+ let bad = false;
101
+ for (const raw of stripBom(text).split(/\r?\n/)) {
102
+ const line = raw.trim();
103
+ if (!line || line.startsWith('#')) continue;
104
+ const heading = /^\[([^\]]+)\]$/.exec(line);
105
+ if (heading) {
106
+ section = heading[1].trim();
107
+ if (section === 'toolchain') saw = true;
108
+ continue;
109
+ }
110
+ if (section !== 'toolchain' || !/^channel\s*=/.test(line)) continue;
111
+ const match = /^channel\s*=\s*(["'])([^"']+)\1\s*(?:#.*)?$/.exec(line);
112
+ if (!match || channel !== null) bad = true;
113
+ else channel = match[2];
114
+ }
115
+ if (bad || (saw && channel === null)) return [pin('rust-toolchain', 'Rust', 'rust-toolchain.toml', 'invalid', null, 'rust-toolchain channel is not a single-line string.')];
116
+ return channel === null ? [] : [configured('rust-toolchain', 'Rust', 'rust-toolchain.toml', channel)];
117
+ }
118
+
119
+ function rustFile(text) {
120
+ const line = versionLine(text);
121
+ if (line === null) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'The pin file has no version.')];
122
+ if (line.startsWith('[')) return [pin('rust-toolchain-file', 'Rust', 'rust-toolchain', 'invalid', null, 'rust-toolchain is not a single version line.')];
123
+ return [configured('rust-toolchain-file', 'Rust', 'rust-toolchain', line)];
124
+ }
125
+
126
+ function goMod(text) {
127
+ let language = null;
128
+ let toolchain = null;
129
+ let bad = false;
130
+ for (const raw of stripBom(text).split(/\r?\n/)) {
131
+ const line = raw.trim();
132
+ if (!line || line.startsWith('//')) continue;
133
+ if (/^go\s/.test(line)) {
134
+ const match = /^go\s+(\S+)$/.exec(line);
135
+ if (!match || language !== null) bad = true;
136
+ else language = match[1];
137
+ } else if (/^toolchain\s/.test(line)) {
138
+ const match = /^toolchain\s+(\S+)$/.exec(line);
139
+ if (!match || toolchain !== null) bad = true;
140
+ else toolchain = match[1];
141
+ }
142
+ }
143
+ if (bad) return [pin('go-mod', 'Go', 'go.mod', 'invalid', null, 'go.mod has a version line that could not be read.')];
144
+ const pins = [];
145
+ if (language) pins.push(configured('go-language', 'Go language', 'go.mod', language));
146
+ if (toolchain) pins.push(configured('go-toolchain', 'Go toolchain', 'go.mod', toolchain));
147
+ return pins;
148
+ }
149
+
150
+ function globalJson(text) {
151
+ let data;
152
+ try { data = JSON.parse(stripBom(text)); } catch {
153
+ return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'global.json could not be read as JSON.')];
154
+ }
155
+ if (!data || typeof data !== 'object' || Array.isArray(data) || data.sdk === undefined) return [];
156
+ if (!data.sdk || typeof data.sdk !== 'object' || Array.isArray(data.sdk) || typeof data.sdk.version !== 'string') {
157
+ return [pin('global-json', '.NET SDK', 'global.json', 'invalid', null, 'sdk.version is not a string.')];
158
+ }
159
+ return [configured('global-json', '.NET SDK', 'global.json', data.sdk.version)];
160
+ }
161
+
162
+ function toolVersions(text) {
163
+ const pins = [];
164
+ const used = new Set();
165
+ let bad = false;
166
+ for (const raw of stripBom(text).split(/\r?\n/)) {
167
+ const line = raw.trim();
168
+ if (!line || line.startsWith('#')) continue;
169
+ const gap = line.search(/\s/);
170
+ const name = (gap === -1 ? line : line.slice(0, gap)).toLowerCase();
171
+ const known = TOOL_VERSIONS[name];
172
+ if (!known) continue;
173
+ const version = gap === -1 ? '' : line.slice(gap).trim();
174
+ if (!version) bad = true;
175
+ else {
176
+ let id = known[0];
177
+ let n = 2;
178
+ while (used.has(id)) id = `${known[0]}-${n++}`;
179
+ used.add(id);
180
+ pins.push(configured(id, known[1], '.tool-versions', version));
181
+ }
182
+ }
183
+ if (bad) return [pin('tool-versions', '.tool-versions', '.tool-versions', 'invalid', null, '.tool-versions has a line that could not be read.')];
184
+ return pins;
185
+ }
186
+
187
+ const PARSERS = {
188
+ '.nvmrc': (text) => textPin('nvmrc', 'Node.js', '.nvmrc', text),
189
+ '.node-version': (text) => textPin('node-version', 'Node.js', '.node-version', text),
190
+ '.python-version': (text) => textPin('python-version', 'Python', '.python-version', text),
191
+ 'package.json': packagePins,
192
+ 'pyproject.toml': pythonRequires,
193
+ 'rust-toolchain.toml': rustToml,
194
+ 'rust-toolchain': rustFile,
195
+ 'go.mod': goMod,
196
+ 'global.json': globalJson,
197
+ '.tool-versions': toolVersions,
198
+ };
199
+
200
+ function inside(file, root) {
201
+ const child = path.resolve(file);
202
+ const base = path.resolve(root);
203
+ const left = process.platform === 'win32' ? child.toLowerCase() : child;
204
+ const right = process.platform === 'win32' ? base.toLowerCase() : base;
205
+ return left.startsWith(right.endsWith(path.sep) ? right : right + path.sep);
206
+ }
207
+
208
+ const FILE_LABELS = {
209
+ '.nvmrc': 'Node.js', '.node-version': 'Node.js', '.python-version': 'Python', 'package.json': 'package.json',
210
+ 'pyproject.toml': 'Python', 'rust-toolchain.toml': 'Rust', 'rust-toolchain': 'Rust', 'go.mod': 'Go',
211
+ 'global.json': '.NET SDK', '.tool-versions': '.tool-versions',
212
+ };
213
+
214
+ function readPin(file, realRoot) {
215
+ const name = path.basename(file);
216
+ const label = FILE_LABELS[name] || name;
217
+ let listed;
218
+ try { listed = fs.lstatSync(file); } catch (err) {
219
+ if (err.code === 'ENOENT') return { identity: { name, state: 'absent' } };
220
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
221
+ }
222
+ let real = file;
223
+ if (listed.isSymbolicLink()) {
224
+ try { real = fs.realpathSync(file); } catch {
225
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
226
+ }
227
+ if (!inside(real, realRoot)) {
228
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is outside the project folder.') };
229
+ }
230
+ }
231
+ let stat;
232
+ try { stat = fs.statSync(real); } catch {
233
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin file could not be read.') };
234
+ }
235
+ if (!stat.isFile()) {
236
+ return { identity: { name, state: 'rejected' }, failure: pin(name, label, name, 'unreadable', null, 'The pin is not a file.') };
237
+ }
238
+ if (stat.size > PIN_LIMIT) {
239
+ return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, failure: pin(name, label, name, 'unreadable', null, 'The pin file is larger than 64 KiB.') };
240
+ }
241
+ return { identity: { name, state: 'file', mtimeMs: stat.mtimeMs, size: stat.size }, file: real };
242
+ }
243
+
244
+ /** Pins and file identity for one project folder. Does not change the process working directory. */
245
+ export function scanPins(cwd, { identityOnly = false } = {}) {
246
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return { error: 'Could not read the project pins.' };
247
+ let root;
248
+ try { root = fs.statSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
249
+ if (!root.isDirectory()) return { error: 'Could not read the project pins.' };
250
+ let realRoot;
251
+ try { realRoot = fs.realpathSync(cwd); } catch { return { error: 'Could not read the project pins.' }; }
252
+ const identity = [];
253
+ const pins = [];
254
+ for (const name of PIN_NAMES) {
255
+ const read = readPin(path.join(cwd, name), realRoot);
256
+ identity.push(read.identity);
257
+ if (identityOnly) continue;
258
+ if (read.failure) pins.push(read.failure);
259
+ else if (read.file) {
260
+ let text;
261
+ try { text = fs.readFileSync(read.file); } catch {
262
+ pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file could not be read.'));
263
+ continue;
264
+ }
265
+ if (text.length > PIN_LIMIT) {
266
+ pins.push(pin(name, FILE_LABELS[name] || name, name, 'unreadable', null, 'The pin file is larger than 64 KiB.'));
267
+ continue;
268
+ }
269
+ pins.push(...PARSERS[name](text.toString('utf8')));
270
+ }
271
+ }
272
+ return { identity: JSON.stringify(identity), ...(identityOnly ? {} : { pins }), cwd: process.cwd() };
273
+ }
274
+
275
+ /** A working folder for a project check. Empty input is refused and does not become the home directory. */
276
+ export function resolveProjectCwd(cwd, { homedir = os.homedir, stat = fs.statSync } = {}) {
277
+ if (typeof cwd !== 'string' || !cwd.trim()) {
278
+ throw Object.assign(new Error('A working folder is required.'), { status: 400, code: 'cwd_required' });
279
+ }
280
+ let dir = cwd.trim();
281
+ if (dir === '~' || dir.startsWith('~/') || dir.startsWith('~\\')) dir = path.join(homedir(), dir.slice(1));
282
+ dir = path.resolve(dir);
283
+ let info;
284
+ try { info = stat(dir); } catch {
285
+ throw Object.assign(new Error(`working directory does not exist: ${dir}`), { status: 400, code: 'bad_cwd' });
286
+ }
287
+ if (!info.isDirectory()) throw Object.assign(new Error(`working directory is not a folder: ${dir}`), { status: 400, code: 'bad_cwd' });
288
+ return dir;
289
+ }
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
8
+ import { scanPins } from './environment-pins.mjs';
8
9
 
9
10
  export const RUNTIMES = [
10
11
  { id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
@@ -52,8 +53,8 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
52
53
  if (/\/(?:shims|\.nodejs)\//.test(normalized) || /\/(?:mise|asdf|volta)(?:\.exe)?$/.test(normalized)) {
53
54
  return { error: 'A version-manager shim was found. Its runtime cannot be checked without activating the manager.' };
54
55
  }
55
- if (platform === 'win32' && /\/windowsapps\//.test(normalized)) {
56
- return { error: 'A Windows execution alias was found. A runtime behind this alias has not been verified.' };
56
+ if (platform === 'win32' && windowsAlias(normalized)) {
57
+ return { error: WINDOWS_ALIAS };
57
58
  }
58
59
  const head = read(real);
59
60
  const binary = head[0] === 0x4d && head[1] === 0x5a // PE
@@ -68,22 +69,43 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
68
69
  return { error: 'A script launcher was found. Passive detection does not execute unrecognized wrappers.' };
69
70
  }
70
71
 
72
+ const WINDOWS_ALIAS = 'A Windows execution alias was found. A runtime behind this alias has not been verified.';
73
+
74
+ function windowsAlias(file) {
75
+ return /[\\/]windowsapps[\\/]/i.test(file);
76
+ }
77
+
71
78
  // The helper has a second, independent overall deadline in Environment. A
72
79
  // timeout here resolves immediately, even if a descendant retains stdout.
73
- export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn } = {}) {
80
+ // The kill is recorded for directory cleanup and does not delay this result.
81
+ export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn, kills = null } = {}) {
74
82
  return new Promise((resolve) => {
75
83
  let child, timer, size = 0, output = '', settled = false;
76
84
  const stop = () => {
77
85
  if (!child?.pid) return;
78
- if (process.platform === 'win32') killWindowsTree(child.pid);
79
- else { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
80
- child.stdout?.destroy(); child.stderr?.destroy(); child.unref();
86
+ const pending = new Promise((done) => {
87
+ let ended = false;
88
+ // Keep cleanup alive after the child is unref'd, even if 'close' never arrives.
89
+ const backup = setTimeout(finishKill, 2000);
90
+ function finishKill() {
91
+ if (ended) return;
92
+ ended = true;
93
+ clearTimeout(backup);
94
+ done();
95
+ }
96
+ // 'close' waits for the process to end. Do not settle when stdio is only destroyed.
97
+ child.once('close', finishKill);
98
+ if (process.platform === 'win32') killWindowsTree(child.pid, finishKill);
99
+ else { try { child.kill('SIGKILL'); } catch { finishKill(); } }
100
+ });
101
+ if (Array.isArray(kills)) kills.push(pending);
102
+ try { child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); } catch { /* the kill is already recorded */ }
81
103
  };
82
104
  const finish = (result, kill = false) => {
83
105
  if (settled) return;
84
106
  settled = true;
85
107
  clearTimeout(timer);
86
- if (kill) stop();
108
+ if (kill) try { stop(); } catch { /* the probe result still stands */ }
87
109
  resolve({ output, ...result });
88
110
  };
89
111
  try {
@@ -108,17 +130,28 @@ const unavailable = {
108
130
  rust: /no default is configured|toolchain .*not installed|could not choose a version|toolchain .*is not installable/i,
109
131
  };
110
132
 
111
- export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe } = {}) {
133
+ export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe, kills = null } = {}) {
112
134
  const { id, label, command, args, pattern } = definition;
113
135
  const base = { id, label, status: 'not_found', version: null, path: null, command, detail: null };
114
136
  const one = async (name) => {
115
- const file = resolve(name, env, platform);
116
- if (!file) return { ...base, command: name };
137
+ // A Windows execution alias must not be inspected or spawned. A later real binary still wins.
138
+ let alias = null;
139
+ const file = resolve(name, env, platform, {
140
+ onSkip(candidate) {
141
+ if (platform !== 'win32' || !windowsAlias(candidate)) return false;
142
+ if (!alias || path.win32.extname(candidate).toLowerCase() === '.exe') alias = candidate;
143
+ return true;
144
+ },
145
+ });
146
+ if (!file) {
147
+ if (!alias) return { ...base, command: name };
148
+ return { ...base, command: name, path: alias, status: 'unavailable', detail: WINDOWS_ALIAS };
149
+ }
117
150
  const row = { ...base, command: name, path: file };
118
151
  try {
119
152
  const executable = inspect(file, id, { platform, env });
120
153
  if (executable.error) return { ...row, status: 'unavailable', detail: executable.error };
121
- const result = await run(executable.file, args, { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd, DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false' }, cwd });
154
+ const result = await run(executable.file, args, { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd, DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false' }, cwd, kills });
122
155
  if (result.error) return { ...row, status: 'failed', detail: result.error };
123
156
  const version = result.code === 0 ? pattern.exec(result.output)?.[1] : null;
124
157
  if (version) return { ...row, status: 'ok', version };
@@ -139,7 +172,7 @@ export async function scanRuntime(definition, { env, cwd, platform = process.pla
139
172
  try {
140
173
  const executable = inspect(row.path, id, { platform, env });
141
174
  if (!executable.error) {
142
- const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd });
175
+ const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd, kills });
143
176
  if (result.code === 0) row.runtimes = result.output.split(/\r?\n/).map((line) => line.match(/^(Microsoft\.[\w.]+) (\d+\.\d+\.\d+(?:-[\w.-]+)?) \[/))
144
177
  .filter(Boolean).map((match) => ({ name: match[1], version: match[2] }));
145
178
  }
@@ -162,10 +195,41 @@ export function detectTools({ env, platform = process.platform, resolve = resolv
162
195
  return tools;
163
196
  }
164
197
 
198
+ // Windows cannot remove a process's current working directory, and a probe
199
+ // killed at its deadline can still hold that directory. Cleanup must not
200
+ // decide whether the scan succeeded.
201
+ export async function releaseScanDirectory(cwd, kills = [], {
202
+ chdir = (dir) => process.chdir(dir),
203
+ remove = fs.rmSync,
204
+ tmpdir = os.tmpdir,
205
+ } = {}) {
206
+ await Promise.all(kills).catch(() => {});
207
+ let temp = '';
208
+ try { temp = tmpdir(); } catch { /* removal is skipped below */ }
209
+ try { if (temp) chdir(temp); } catch { /* still try to remove the scan directory */ }
210
+ if (!cwd || !temp) return;
211
+ if (path.dirname(cwd) !== path.resolve(temp) || !path.basename(cwd).startsWith('agent-guild-environment-')) return;
212
+ try {
213
+ remove(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 40 });
214
+ } catch { /* a leftover lock must not fail the scan */ }
215
+ }
216
+
217
+ if ((process.argv[2] === '--scan-pins' || process.argv[2] === '--pin-identity') && process.send) {
218
+ // Read the named folder by absolute path. Never chdir into it: a project
219
+ // directory can activate tools on entry, and this check must not.
220
+ const started = process.cwd();
221
+ const result = scanPins(process.argv[3], { identityOnly: process.argv[2] === '--pin-identity' });
222
+ process.send({ ...result, cwd: process.cwd() });
223
+ if (process.cwd() !== started) process.chdir(started);
224
+ process.send({ done: true });
225
+ }
226
+
165
227
  if (process.argv[2] === '--scan-environment' && process.send) {
166
228
  // A fresh temporary directory contains no project files. Manager env is
167
229
  // retained; no selected shell, project or session is inspected or changed.
168
230
  let cwd;
231
+ let finished = false;
232
+ const kills = [];
169
233
  try {
170
234
  cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-environment-'));
171
235
  process.chdir(cwd);
@@ -174,16 +238,13 @@ if (process.argv[2] === '--scan-environment' && process.send) {
174
238
  // Two batches bound concurrency while giving each runtime its own result.
175
239
  for (let i = 0; i < RUNTIMES.length; i += 3) {
176
240
  await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
177
- const runtime = await scanRuntime(definition, { env, cwd });
241
+ const runtime = await scanRuntime(definition, { env, cwd, kills });
178
242
  process.send({ runtime });
179
243
  }));
180
244
  }
245
+ finished = true;
181
246
  } finally {
182
- // Windows cannot remove a process's current working directory.
183
- process.chdir(os.tmpdir());
184
- if (cwd && path.dirname(cwd) === path.resolve(os.tmpdir()) && path.basename(cwd).startsWith('agent-guild-environment-')) {
185
- fs.rmSync(cwd, { recursive: true, force: true });
186
- }
247
+ await releaseScanDirectory(cwd, kills);
187
248
  }
188
- process.send({ done: true });
249
+ if (finished) process.send({ done: true });
189
250
  }