@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.1",
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 is named by its opt link, which an upgrade keeps, rather than by
31
- // the versioned Cellar folder the upgrade deletes.
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
- * Homebrew runs Node.js from `<prefix>/Cellar/<formula>/<version>`, which
142
- * the next upgrade deletes. `<prefix>/opt/<formula>` follows the formula
143
- * across upgrades, so it names Node.js instead when it leads to the same file.
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 match = execPath.match(/^(.+)\/Cellar\/([^/]+)\/[^/]+\/bin\/node$/);
147
- if (!match) return execPath;
148
- const opt = `${match[1]}/opt/${match[2]}/bin/node`;
149
- try {
150
- return realpath(opt) === realpath(execPath) ? opt : execPath;
151
- } catch {
152
- return execPath;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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((c) => isExecutable(c));
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' && /\/windowsapps\//.test(normalized)) {
56
- return { error: 'A Windows execution alias was found. A runtime behind this alias has not been verified.' };
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
- export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn } = {}) {
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
- 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();
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
- const file = resolve(name, env, platform);
116
- if (!file) return { ...base, command: name };
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
- // 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
- }
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
- run.child.once('exit', () => finish('The environment check stopped before finishing.'));
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.'); }
@@ -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 ? 'Some checks could not finish'
23
+ : data?.error ? data.error
24
24
  : !data ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
25
25
  }
26
26