@oddessentials/agent-guild 0.33.1 → 0.33.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -337,7 +337,7 @@ Any other tool or script can report agents and its model too. See
|
|
|
337
337
|
been uninstalled or moved removes itself at the next sign-in. With nvm,
|
|
338
338
|
the package lives in the Node.js version's folder, so uninstalling that
|
|
339
339
|
version counts as uninstalling Agent Guild; a Homebrew Node.js upgrade
|
|
340
|
-
does not. On macOS and Linux what the entry runs at sign-in, including a
|
|
340
|
+
or a Snap Node.js refresh does not. On macOS and Linux what the entry runs at sign-in, including a
|
|
341
341
|
missing Node.js, is appended to `manager.log`.
|
|
342
342
|
The entry saves the manager's listening port, including the assigned port
|
|
343
343
|
when started with `AGENT_GUILD_PORT=0`. Enabled entries are updated when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.2",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
// an entry whose package script is gone removes itself at sign-in instead
|
|
28
28
|
// of failing there at every sign-in after. A missing Node.js alone keeps
|
|
29
29
|
// the entry: the next manager to start points it at its own. Homebrew's
|
|
30
|
-
// Node.js
|
|
31
|
-
// the versioned
|
|
30
|
+
// and Snap's Node.js are named by the link an upgrade keeps (opt, current)
|
|
31
|
+
// rather than by the versioned folder the upgrade deletes.
|
|
32
32
|
|
|
33
33
|
import fs from 'node:fs';
|
|
34
34
|
import os from 'node:os';
|
|
@@ -138,19 +138,31 @@ export function posixCommand({ launcher, ...paths }) {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
*
|
|
142
|
-
* the
|
|
143
|
-
*
|
|
141
|
+
* Versioned Node.js folders that an upgrade deletes, each with the link that
|
|
142
|
+
* follows the package across upgrades. Homebrew runs Node.js from
|
|
143
|
+
* `<prefix>/Cellar/<formula>/<version>`, kept as `<prefix>/opt/<formula>`.
|
|
144
|
+
* A snap runs it from `<snaps>/<snap>/<revision>`, where `<snaps>` is
|
|
145
|
+
* `/snap` or `/var/lib/snapd/snap`, kept as `<snaps>/<snap>/current`; snapd
|
|
146
|
+
* removes old revisions after a refresh.
|
|
144
147
|
*/
|
|
148
|
+
const VERSIONED_NODE = [
|
|
149
|
+
[/^(.+)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/, (prefix, formula) => `${prefix}/opt/${formula}/bin/node`],
|
|
150
|
+
[/^(.*\/snap)\/([^/]+)\/x?\d+\/bin\/node$/, (snaps, snap) => `${snaps}/${snap}/current/bin/node`],
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** Names Node.js by the link that survives upgrades, when that link leads to the same file. */
|
|
145
154
|
export function stableExecPath(execPath, realpath = fs.realpathSync) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
for (const [pattern, link] of VERSIONED_NODE) {
|
|
156
|
+
const match = execPath.match(pattern);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
const stable = link(...match.slice(1));
|
|
159
|
+
try {
|
|
160
|
+
return realpath(stable) === realpath(execPath) ? stable : execPath;
|
|
161
|
+
} catch {
|
|
162
|
+
return execPath;
|
|
163
|
+
}
|
|
153
164
|
}
|
|
165
|
+
return execPath;
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
const xml = (value) => value.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
@@ -55,13 +55,15 @@ function candidateGroups(command, env, platform) {
|
|
|
55
55
|
* Resolve `command` to an absolute path, or return null when it is not
|
|
56
56
|
* installed. Commands that already contain a path separator are checked as-is.
|
|
57
57
|
* `isExecutable` is injectable so tests can simulate another platform.
|
|
58
|
+
* `onSkip` drops a candidate before that check, without touching the filesystem.
|
|
58
59
|
*/
|
|
59
60
|
export function resolveCommand(command, env = process.env, platform = process.platform, {
|
|
60
61
|
isExecutable = (file) => isExecutableFile(file, platform),
|
|
62
|
+
onSkip = () => false,
|
|
61
63
|
} = {}) {
|
|
62
64
|
if (!command) return null;
|
|
63
65
|
for (const group of candidateGroups(command, env, platform)) {
|
|
64
|
-
const hit = group.find((
|
|
66
|
+
const hit = group.find((candidate) => !onSkip(candidate) && isExecutable(candidate));
|
|
65
67
|
if (hit) return hit;
|
|
66
68
|
}
|
|
67
69
|
return null;
|
|
@@ -52,8 +52,8 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
52
52
|
if (/\/(?:shims|\.nodejs)\//.test(normalized) || /\/(?:mise|asdf|volta)(?:\.exe)?$/.test(normalized)) {
|
|
53
53
|
return { error: 'A version-manager shim was found. Its runtime cannot be checked without activating the manager.' };
|
|
54
54
|
}
|
|
55
|
-
if (platform === 'win32' &&
|
|
56
|
-
return { error:
|
|
55
|
+
if (platform === 'win32' && windowsAlias(normalized)) {
|
|
56
|
+
return { error: WINDOWS_ALIAS };
|
|
57
57
|
}
|
|
58
58
|
const head = read(real);
|
|
59
59
|
const binary = head[0] === 0x4d && head[1] === 0x5a // PE
|
|
@@ -68,22 +68,43 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
|
|
|
68
68
|
return { error: 'A script launcher was found. Passive detection does not execute unrecognized wrappers.' };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const WINDOWS_ALIAS = 'A Windows execution alias was found. A runtime behind this alias has not been verified.';
|
|
72
|
+
|
|
73
|
+
function windowsAlias(file) {
|
|
74
|
+
return /[\\/]windowsapps[\\/]/i.test(file);
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
// The helper has a second, independent overall deadline in Environment. A
|
|
72
78
|
// timeout here resolves immediately, even if a descendant retains stdout.
|
|
73
|
-
|
|
79
|
+
// The kill is recorded for directory cleanup and does not delay this result.
|
|
80
|
+
export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn, kills = null } = {}) {
|
|
74
81
|
return new Promise((resolve) => {
|
|
75
82
|
let child, timer, size = 0, output = '', settled = false;
|
|
76
83
|
const stop = () => {
|
|
77
84
|
if (!child?.pid) return;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
const pending = new Promise((done) => {
|
|
86
|
+
let ended = false;
|
|
87
|
+
// Keep cleanup alive after the child is unref'd, even if 'close' never arrives.
|
|
88
|
+
const backup = setTimeout(finishKill, 2000);
|
|
89
|
+
function finishKill() {
|
|
90
|
+
if (ended) return;
|
|
91
|
+
ended = true;
|
|
92
|
+
clearTimeout(backup);
|
|
93
|
+
done();
|
|
94
|
+
}
|
|
95
|
+
// 'close' waits for the process to end. Do not settle when stdio is only destroyed.
|
|
96
|
+
child.once('close', finishKill);
|
|
97
|
+
if (process.platform === 'win32') killWindowsTree(child.pid, finishKill);
|
|
98
|
+
else { try { child.kill('SIGKILL'); } catch { finishKill(); } }
|
|
99
|
+
});
|
|
100
|
+
if (Array.isArray(kills)) kills.push(pending);
|
|
101
|
+
try { child.stdout?.destroy(); child.stderr?.destroy(); child.unref(); } catch { /* the kill is already recorded */ }
|
|
81
102
|
};
|
|
82
103
|
const finish = (result, kill = false) => {
|
|
83
104
|
if (settled) return;
|
|
84
105
|
settled = true;
|
|
85
106
|
clearTimeout(timer);
|
|
86
|
-
if (kill) stop();
|
|
107
|
+
if (kill) try { stop(); } catch { /* the probe result still stands */ }
|
|
87
108
|
resolve({ output, ...result });
|
|
88
109
|
};
|
|
89
110
|
try {
|
|
@@ -108,17 +129,28 @@ const unavailable = {
|
|
|
108
129
|
rust: /no default is configured|toolchain .*not installed|could not choose a version|toolchain .*is not installable/i,
|
|
109
130
|
};
|
|
110
131
|
|
|
111
|
-
export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe } = {}) {
|
|
132
|
+
export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe, kills = null } = {}) {
|
|
112
133
|
const { id, label, command, args, pattern } = definition;
|
|
113
134
|
const base = { id, label, status: 'not_found', version: null, path: null, command, detail: null };
|
|
114
135
|
const one = async (name) => {
|
|
115
|
-
|
|
116
|
-
|
|
136
|
+
// A Windows execution alias must not be inspected or spawned. A later real binary still wins.
|
|
137
|
+
let alias = null;
|
|
138
|
+
const file = resolve(name, env, platform, {
|
|
139
|
+
onSkip(candidate) {
|
|
140
|
+
if (platform !== 'win32' || !windowsAlias(candidate)) return false;
|
|
141
|
+
if (!alias || path.win32.extname(candidate).toLowerCase() === '.exe') alias = candidate;
|
|
142
|
+
return true;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
if (!file) {
|
|
146
|
+
if (!alias) return { ...base, command: name };
|
|
147
|
+
return { ...base, command: name, path: alias, status: 'unavailable', detail: WINDOWS_ALIAS };
|
|
148
|
+
}
|
|
117
149
|
const row = { ...base, command: name, path: file };
|
|
118
150
|
try {
|
|
119
151
|
const executable = inspect(file, id, { platform, env });
|
|
120
152
|
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 });
|
|
153
|
+
const result = await run(executable.file, args, { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd, DOTNET_GENERATE_ASPNET_CERTIFICATE: 'false' }, cwd, kills });
|
|
122
154
|
if (result.error) return { ...row, status: 'failed', detail: result.error };
|
|
123
155
|
const version = result.code === 0 ? pattern.exec(result.output)?.[1] : null;
|
|
124
156
|
if (version) return { ...row, status: 'ok', version };
|
|
@@ -139,7 +171,7 @@ export async function scanRuntime(definition, { env, cwd, platform = process.pla
|
|
|
139
171
|
try {
|
|
140
172
|
const executable = inspect(row.path, id, { platform, env });
|
|
141
173
|
if (!executable.error) {
|
|
142
|
-
const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd });
|
|
174
|
+
const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd, kills });
|
|
143
175
|
if (result.code === 0) row.runtimes = result.output.split(/\r?\n/).map((line) => line.match(/^(Microsoft\.[\w.]+) (\d+\.\d+\.\d+(?:-[\w.-]+)?) \[/))
|
|
144
176
|
.filter(Boolean).map((match) => ({ name: match[1], version: match[2] }));
|
|
145
177
|
}
|
|
@@ -162,10 +194,31 @@ export function detectTools({ env, platform = process.platform, resolve = resolv
|
|
|
162
194
|
return tools;
|
|
163
195
|
}
|
|
164
196
|
|
|
197
|
+
// Windows cannot remove a process's current working directory, and a probe
|
|
198
|
+
// killed at its deadline can still hold that directory. Cleanup must not
|
|
199
|
+
// decide whether the scan succeeded.
|
|
200
|
+
export async function releaseScanDirectory(cwd, kills = [], {
|
|
201
|
+
chdir = (dir) => process.chdir(dir),
|
|
202
|
+
remove = fs.rmSync,
|
|
203
|
+
tmpdir = os.tmpdir,
|
|
204
|
+
} = {}) {
|
|
205
|
+
await Promise.all(kills).catch(() => {});
|
|
206
|
+
let temp = '';
|
|
207
|
+
try { temp = tmpdir(); } catch { /* removal is skipped below */ }
|
|
208
|
+
try { if (temp) chdir(temp); } catch { /* still try to remove the scan directory */ }
|
|
209
|
+
if (!cwd || !temp) return;
|
|
210
|
+
if (path.dirname(cwd) !== path.resolve(temp) || !path.basename(cwd).startsWith('agent-guild-environment-')) return;
|
|
211
|
+
try {
|
|
212
|
+
remove(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 40 });
|
|
213
|
+
} catch { /* a leftover lock must not fail the scan */ }
|
|
214
|
+
}
|
|
215
|
+
|
|
165
216
|
if (process.argv[2] === '--scan-environment' && process.send) {
|
|
166
217
|
// A fresh temporary directory contains no project files. Manager env is
|
|
167
218
|
// retained; no selected shell, project or session is inspected or changed.
|
|
168
219
|
let cwd;
|
|
220
|
+
let finished = false;
|
|
221
|
+
const kills = [];
|
|
169
222
|
try {
|
|
170
223
|
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-environment-'));
|
|
171
224
|
process.chdir(cwd);
|
|
@@ -174,16 +227,13 @@ if (process.argv[2] === '--scan-environment' && process.send) {
|
|
|
174
227
|
// Two batches bound concurrency while giving each runtime its own result.
|
|
175
228
|
for (let i = 0; i < RUNTIMES.length; i += 3) {
|
|
176
229
|
await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
|
|
177
|
-
const runtime = await scanRuntime(definition, { env, cwd });
|
|
230
|
+
const runtime = await scanRuntime(definition, { env, cwd, kills });
|
|
178
231
|
process.send({ runtime });
|
|
179
232
|
}));
|
|
180
233
|
}
|
|
234
|
+
finished = true;
|
|
181
235
|
} finally {
|
|
182
|
-
|
|
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
|
-
}
|
|
236
|
+
await releaseScanDirectory(cwd, kills);
|
|
187
237
|
}
|
|
188
|
-
process.send({ done: true });
|
|
238
|
+
if (finished) process.send({ done: true });
|
|
189
239
|
}
|
|
@@ -61,7 +61,11 @@ export class Environment extends EventEmitter {
|
|
|
61
61
|
if (message?.done) finish();
|
|
62
62
|
});
|
|
63
63
|
run.child.once('error', () => finish('Could not start the environment check.'));
|
|
64
|
-
|
|
64
|
+
// Let messages already queued win. Exit is a failed check only when a runtime never reported.
|
|
65
|
+
run.child.once('exit', () => setImmediate(() => {
|
|
66
|
+
if (this.run !== run) return;
|
|
67
|
+
finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
|
|
68
|
+
}));
|
|
65
69
|
run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
|
|
66
70
|
run.timer.unref();
|
|
67
71
|
} catch { finish('Could not start the environment check.'); }
|
package/web/environment.js
CHANGED
|
@@ -20,7 +20,7 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
|
|
|
20
20
|
const note = host.querySelector('.environment-note');
|
|
21
21
|
note.textContent = error ? 'Environment check unavailable'
|
|
22
22
|
: data?.refreshing ? data.checkedAt ? 'Refreshing · showing previous check' : 'Checking environment…'
|
|
23
|
-
: data?.error ?
|
|
23
|
+
: data?.error ? data.error
|
|
24
24
|
: !data ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
|
|
25
25
|
}
|
|
26
26
|
|