@oddessentials/agent-guild 0.35.0 → 0.36.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.
package/README.md CHANGED
@@ -39,10 +39,12 @@ terminal.
39
39
  | Node.js | 22 or newer |
40
40
  | Windows | 10 version 1809 or later, x64 or arm64. No WSL needed. |
41
41
  | macOS | 11 or later, Intel or Apple silicon |
42
- | Linux | x64 or arm64, with a desktop or over SSH |
42
+ | Linux | x64 or arm64 with glibc 2.28 or later (for example Debian 10, Ubuntu 20.04 or RHEL 8), with a desktop or over SSH |
43
43
 
44
- No compiler is needed. A missing tool shows **Install** on its card;
45
- Antigravity CLI, which is not on npm, shows its install command instead.
44
+ No compiler is needed, except on Linux without glibc, such as Alpine:
45
+ `agent-guild` prints the commands that build its terminal library there. A
46
+ missing tool shows **Install** on its card; Antigravity CLI, which is not on
47
+ npm, shows its install command instead.
46
48
 
47
49
  ## Setup
48
50
 
@@ -21,6 +21,7 @@ import {
21
21
  } from '../src/manager/config.mjs';
22
22
  import { defaultBoot, serviceFor, spawnManager, startService } from '../src/manager/launch.mjs';
23
23
  import { recordSignIn } from '../src/manager/autostart.mjs';
24
+ import { ptyProblem } from '../src/manager/pty.mjs';
24
25
  import { EXIT_PORT_IN_USE, describeStartup, startupState, startupSummary, unitPort } from '../src/manager/systemd-service.mjs';
25
26
 
26
27
  function usage() {
@@ -106,6 +107,10 @@ async function ensureManager({ port = null } = {}) {
106
107
  const running = await health(knownUrl);
107
108
  if (running) return { url: knownUrl, started: false, version: running.version };
108
109
 
110
+ // Say why no manager can run here rather than start one that fails.
111
+ const problem = ptyProblem();
112
+ if (problem) throw new Error(problem);
113
+
109
114
  // While the boot service is on for this port, systemd is the one that starts a manager.
110
115
  const { boot, note } = await serviceFor(listenPort);
111
116
  if (boot) return startService({ boot, port: listenPort, url: expectedUrl, health });
@@ -161,7 +166,10 @@ async function requestShutdown(url, { restart = false } = {}) {
161
166
  headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
162
167
  body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
163
168
  });
164
- if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: HTTP ${res.status}`);
169
+ if (!res.ok) {
170
+ const { error } = await res.json().catch(() => ({}));
171
+ throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
172
+ }
165
173
  const body = await res.json().catch(() => ({}));
166
174
  const running = body.running ?? 0;
167
175
  if (running > 0) console.log(`Ending ${running} running session(s).`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
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": {
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
8
8
  import { scanPins } from './environment-pins.mjs';
9
+ import { systemInfo, dockerInfo, isWsl, wslInteropEnabled } from './environment-system.mjs';
9
10
 
10
11
  export const RUNTIMES = [
11
12
  { id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
@@ -57,10 +58,7 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
57
58
  return { error: WINDOWS_ALIAS };
58
59
  }
59
60
  const head = read(real);
60
- const binary = head[0] === 0x4d && head[1] === 0x5a // PE
61
- || head[0] === 0x7f && head.subarray(1, 4).toString() === 'ELF'
62
- || ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(head.subarray(0, 4).toString('hex'));
63
- if (binary) {
61
+ if (nativeProgram(head)) {
64
62
  // rustup dispatches using argv[0]; preserve its rustc proxy's name.
65
63
  return { file: id === 'rust' && /\/rustup(?:\.exe)?$/.test(normalized) ? file : real };
66
64
  }
@@ -71,6 +69,12 @@ export function passiveExecutable(file, id, { platform = process.platform, env =
71
69
 
72
70
  const WINDOWS_ALIAS = 'A Windows execution alias was found. A runtime behind this alias has not been verified.';
73
71
 
72
+ function nativeProgram(head) {
73
+ return head[0] === 0x4d && head[1] === 0x5a // PE
74
+ || head[0] === 0x7f && head.subarray(1, 4).toString() === 'ELF'
75
+ || ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(head.subarray(0, 4).toString('hex'));
76
+ }
77
+
74
78
  function windowsAlias(file) {
75
79
  return /[\\/]windowsapps[\\/]/i.test(file);
76
80
  }
@@ -78,7 +82,7 @@ function windowsAlias(file) {
78
82
  // The helper has a second, independent overall deadline in Environment. A
79
83
  // timeout here resolves immediately, even if a descendant retains stdout.
80
84
  // 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 } = {}) {
85
+ export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, limit = LIMIT, spawnProcess = spawn, kills = null } = {}) {
82
86
  return new Promise((resolve) => {
83
87
  let child, timer, size = 0, output = '', settled = false;
84
88
  const stop = () => {
@@ -113,7 +117,7 @@ export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnPr
113
117
  timer = setTimeout(() => finish({ error: 'Version check timed out.' }, true), timeoutMs);
114
118
  for (const stream of [child.stdout, child.stderr]) stream.on('data', (chunk) => {
115
119
  size += chunk.length;
116
- if (size > LIMIT) return finish({ error: 'Version check produced too much output.' }, true);
120
+ if (size > limit) return finish({ error: 'Version check produced too much output.' }, true);
117
121
  output += chunk.toString();
118
122
  });
119
123
  child.on('error', () => finish({ error: 'Could not run the resolved executable.' }, true));
@@ -195,6 +199,229 @@ export function detectTools({ env, platform = process.platform, resolve = resolv
195
199
  return tools;
196
200
  }
197
201
 
202
+ // Design tools an agent can drive from a terminal. A version is read only from
203
+ // a native program whose real name is the tool's own: a snap, a Flatpak
204
+ // launcher or another wrapper is reported by presence. Windows `convert` is a
205
+ // disk utility, so ImageMagick is only `magick` there.
206
+ export const DESIGN_TOOLS = [
207
+ {
208
+ id: 'blender', label: 'Blender', args: ['--version', '--factory-startup'],
209
+ commands: () => ['blender'],
210
+ pattern: /^Blender (\d+\.\d+(?:\.\d+)?)/m, program: /^blender(?:\.exe)?$/i, installed: /^Blender\b/i,
211
+ },
212
+ {
213
+ id: 'ffmpeg', label: 'FFmpeg', args: ['-version'],
214
+ commands: () => ['ffmpeg'],
215
+ pattern: /^ffmpeg version n?(\d+(?:\.\d+)+|N-\d+)/m, program: /^ffmpeg(?:\.exe)?$/i, installed: /^FFmpeg\b/i,
216
+ },
217
+ {
218
+ // GIMP's window program on Windows does not print its version; the console build does.
219
+ id: 'gimp', label: 'GIMP', args: ['--version'],
220
+ commands: (platform) => platform === 'win32' ? ['gimp-console'] : ['gimp-console', 'gimp'],
221
+ pattern: /version (\d+\.\d+\.\d+)/, installed: /^GIMP\b/i,
222
+ program: (platform) => platform === 'win32' ? /^gimp-console(?:-[\d.]+)?\.exe$/i : /^gimp(?:-console)?(?:-[\d.]+)?$/i,
223
+ },
224
+ {
225
+ id: 'inkscape', label: 'Inkscape', args: ['--version'],
226
+ commands: () => ['inkscape'],
227
+ pattern: /^Inkscape (\d+\.\d+(?:\.\d+)?)/m, program: /^inkscape(?:\.com|\.exe)?$/i, installed: /^Inkscape\b/i,
228
+ },
229
+ {
230
+ id: 'imagemagick', label: 'ImageMagick', args: ['-version'],
231
+ commands: (platform) => platform === 'win32' ? ['magick'] : ['magick', 'convert'],
232
+ pattern: /^Version: ImageMagick (\d+\.\d+\.\d+(?:-\d+)?)/m, program: /^(?:magick|convert)(?:-im\d[\w.]*)?(?:\.exe)?$/i, installed: /^ImageMagick\b/i,
233
+ },
234
+ ];
235
+ const DESIGN_TIMEOUT_MS = 3000;
236
+ const REGISTRY_LIMIT = 1024 * 1024;
237
+ const UNINSTALL_KEYS = [
238
+ 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
239
+ 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
240
+ 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
241
+ ];
242
+ // Paths inside an install folder, Windows. A RegExp segment matches a directory entry.
243
+ const WINDOWS_FILES = {
244
+ blender: [['blender.exe']],
245
+ ffmpeg: [['bin', 'ffmpeg.exe'], ['ffmpeg.exe'], [/^ffmpeg-/i, 'bin', 'ffmpeg.exe']],
246
+ gimp: [['bin', 'gimp-console.exe'], ['bin', /^gimp-console-[\d.]+\.exe$/i]],
247
+ inkscape: [['bin', 'inkscape.com'], ['bin', 'inkscape.exe']],
248
+ imagemagick: [['magick.exe']],
249
+ };
250
+
251
+ // Usual install locations, used only when PATH has no match. Each entry is an
252
+ // absolute root followed by segments below it.
253
+ export function designLocations(id, { platform = process.platform, env = process.env, registry = [] } = {}) {
254
+ if (platform === 'win32') {
255
+ const programFiles = [value(env, 'ProgramFiles'), value(env, 'ProgramW6432'), 'C:\\Program Files'].filter(Boolean);
256
+ const profile = value(env, 'USERPROFILE');
257
+ const local = value(env, 'LOCALAPPDATA') || (profile && path.win32.join(profile, 'AppData', 'Local'));
258
+ const folders = {
259
+ blender: programFiles.map((root) => [root, 'Blender Foundation', /^Blender/i]),
260
+ ffmpeg: [],
261
+ gimp: [...programFiles.map((root) => [root, /^GIMP/i]), ...(local ? [[local, 'Programs', /^GIMP/i]] : [])],
262
+ inkscape: programFiles.map((root) => [root, 'Inkscape']),
263
+ imagemagick: programFiles.map((root) => [root, /^ImageMagick/i]),
264
+ }[id];
265
+ return [...registry.map((dir) => [dir]), ...folders].flatMap((folder) => WINDOWS_FILES[id].map((file) => [...folder, ...file]));
266
+ }
267
+ const home = value(env, 'HOME') || os.homedir();
268
+ const bins = platform === 'darwin'
269
+ ? ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
270
+ : ['/usr/local/bin', '/usr/bin', '/snap/bin', '/home/linuxbrew/.linuxbrew/bin'];
271
+ const flatpaks = ['/var/lib/flatpak/exports/bin', path.posix.join(home, '.local/share/flatpak/exports/bin')];
272
+ const apps = ['/Applications', path.posix.join(home, 'Applications')];
273
+ const inBins = (names) => bins.flatMap((dir) => names.map((name) => [dir, name]));
274
+ const flatpak = (app) => platform === 'linux' ? flatpaks.map((dir) => [dir, app]) : [];
275
+ const bundle = (app, file) => platform === 'darwin' ? apps.map((dir) => [dir, app, 'Contents', 'MacOS', file]) : [];
276
+ return {
277
+ blender: [...bundle(/^Blender.*\.app$/i, 'Blender'), ...inBins(['blender']), ...flatpak('org.blender.Blender')],
278
+ ffmpeg: inBins(['ffmpeg']),
279
+ gimp: [...bundle(/^GIMP.*\.app$/i, /^gimp(?:-[\d.]+)?$/i), ...inBins(['gimp-console', 'gimp']), ...flatpak('org.gimp.GIMP')],
280
+ inkscape: [...bundle(/^Inkscape.*\.app$/i, 'inkscape'), ...inBins(['inkscape']), ...flatpak('org.inkscape.Inkscape')],
281
+ imagemagick: inBins(platform === 'darwin' ? ['magick'] : ['magick', 'convert']),
282
+ }[id];
283
+ }
284
+
285
+ // The first existing file among the locations, in order. Directory listings are sorted.
286
+ export function findInstalled(locations, { platform = process.platform, readdir = fs.readdirSync, isFile = (file) => isExecutable(file, platform) } = {}) {
287
+ const join = platform === 'win32' ? path.win32.join : path.posix.join;
288
+ for (const [root, ...segments] of locations) {
289
+ let paths = [root];
290
+ for (const segment of segments) {
291
+ paths = paths.flatMap((dir) => {
292
+ if (typeof segment === 'string') return [join(dir, segment)];
293
+ try { return readdir(dir).filter((name) => segment.test(name)).sort().map((name) => join(dir, name)); } catch { return []; }
294
+ });
295
+ }
296
+ const hit = paths.find((file) => isFile(file));
297
+ if (hit) return hit;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ function isExecutable(file, platform) {
303
+ try {
304
+ if (!fs.statSync(file).isFile()) return false;
305
+ if (platform !== 'win32') fs.accessSync(file, fs.constants.X_OK);
306
+ return true;
307
+ } catch { return false; }
308
+ }
309
+
310
+ // Install folders from Windows uninstall entries whose name is a design tool.
311
+ // Each query is bounded; a failed query adds nothing.
312
+ export async function windowsInstallFolders({ env, cwd, run = runProbe, kills = null } = {}) {
313
+ const reg = path.win32.join(value(env, 'SystemRoot') || 'C:\\Windows', 'System32', 'reg.exe');
314
+ const query = (args) => run(reg, ['query', ...args], { env, cwd, kills, timeoutMs: DESIGN_TIMEOUT_MS, limit: REGISTRY_LIMIT })
315
+ .then((result) => result.code === 0 ? result.output : '');
316
+ const entries = (output, name) => {
317
+ const found = [];
318
+ let key = null;
319
+ for (const line of output.split(/\r?\n/)) {
320
+ if (/^HKEY_/.test(line)) key = line.trim();
321
+ const match = line.match(new RegExp(`^\\s+${name}\\s+REG_(?:EXPAND_)?SZ\\s+(.+?)\\s*$`));
322
+ if (key && match) found.push({ key, value: match[1] });
323
+ }
324
+ return found;
325
+ };
326
+ const named = (await Promise.all(UNINSTALL_KEYS.map((root) => query([root, '/s', '/v', 'DisplayName']))))
327
+ .flatMap((output) => entries(output, 'DisplayName'))
328
+ .map(({ key, value: name }) => ({ key, tool: DESIGN_TOOLS.find((tool) => tool.installed.test(name)) }))
329
+ .filter(({ tool }) => tool);
330
+ const folders = Object.fromEntries(DESIGN_TOOLS.map(({ id }) => [id, []]));
331
+ await Promise.all(named.map(async ({ key, tool }) => {
332
+ const folder = entries(await query([key, '/v', 'InstallLocation']), 'InstallLocation')[0]?.value.replace(/^"(.*)"$/, '$1');
333
+ if (folder && path.win32.isAbsolute(folder)) folders[tool.id].push(folder);
334
+ }));
335
+ return folders;
336
+ }
337
+
338
+ // A Homebrew cask's command wrapper is exactly `exec "<program in the app>" "$@"`.
339
+ // Only that exact form is followed; any other script stays unchecked.
340
+ const HOMEBREW_WRAPPER = /\/Caskroom\/[^/]+\/[^/]+\/\.homebrew-command-wrappers\/[^/]+$/;
341
+
342
+ function homebrewTarget(head) {
343
+ return head.toString('utf8').match(/^#!\/bin\/(?:ba)?sh\nexec "(\/[^"$`\\\n]+)"\s+"\$@"\n?$/)?.[1] ?? null;
344
+ }
345
+
346
+ // Why a found program's version is not read, or the program to run.
347
+ export function designProgram(file, tool, { platform = process.platform, realpath = fs.realpathSync, read = readHead } = {}) {
348
+ let real = realpath(file);
349
+ const normalized = real.replaceAll('\\', '/');
350
+ if (/\/flatpak\/exports\/bin\//.test(file.replaceAll('\\', '/'))) return { reason: 'A Flatpak app. Its version is not checked.' };
351
+ if (/(?:^|\/)snap$/.test(normalized)) return { reason: 'A snap. Its version is not checked.' };
352
+ const head = read(real);
353
+ if (!nativeProgram(head)) {
354
+ const target = platform === 'darwin' && HOMEBREW_WRAPPER.test(normalized) ? homebrewTarget(head) : null;
355
+ if (!target) return { reason: 'A launcher script. Its version is not checked.' };
356
+ real = realpath(target);
357
+ if (!nativeProgram(read(real))) return { reason: 'A launcher script. Its version is not checked.' };
358
+ }
359
+ const name = path.posix.basename(real.replaceAll('\\', '/'));
360
+ const program = typeof tool.program === 'function' ? tool.program(platform) : tool.program;
361
+ if (!program.test(name)) return { reason: `This runs ${name}, so its version is not checked.` };
362
+ return { file: real };
363
+ }
364
+
365
+ // GTK programs write a D-Bus keyring and profile folders even for --version.
366
+ // Point every home and profile location at the scan's own temporary folder.
367
+ export function designEnv(env, cwd) {
368
+ const out = probeEnv(env);
369
+ const overrides = { DBUS_SESSION_BUS_ADDRESS: 'disabled:' };
370
+ for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME', 'INKSCAPE_PROFILE_DIR', 'GIMP2_DIRECTORY', 'GIMP3_DIRECTORY']) overrides[key] = cwd;
371
+ for (const key of Object.keys(out)) if (Object.hasOwn(overrides, key.toUpperCase())) delete out[key];
372
+ return { ...out, ...overrides };
373
+ }
374
+
375
+ export async function scanDesignTool(tool, {
376
+ env, cwd, platform = process.platform, resolve = resolveCommand, installed = async () => null,
377
+ inspect = designProgram, run = runProbe, kills = null, wslInterop = false,
378
+ } = {}) {
379
+ const { id, label, args, pattern } = tool;
380
+ const base = { id, label, status: 'not_found', version: null, path: null, command: null, detail: null };
381
+ let command = null, file = null;
382
+ for (const name of tool.commands(platform)) {
383
+ file = resolve(name, env, platform, { onSkip: (candidate) => platform === 'win32' && windowsAlias(candidate) });
384
+ if (file) { command = name; break; }
385
+ }
386
+ // WSL puts the Windows PATH on its own. Agents there run a Windows program by its .exe name.
387
+ if (!file && wslInterop) {
388
+ for (const name of tool.commands('win32').map((name) => `${name}.exe`)) {
389
+ file = resolve(name, env, platform);
390
+ if (file) return { ...base, status: 'on_path', path: file, command: name, detail: 'A Windows program, run through WSL. Its version is not checked.' };
391
+ }
392
+ }
393
+ const onPath = Boolean(file);
394
+ if (!file) file = await installed(id);
395
+ if (!file) return { ...base, detail: 'Not on PATH or in the usual install locations.' };
396
+ const row = { ...base, status: onPath ? 'on_path' : 'not_on_path', path: file, command };
397
+ try {
398
+ const program = inspect(file, tool, { platform });
399
+ if (program.reason) return { ...row, detail: program.reason };
400
+ const result = await run(program.file, args, { env: designEnv(env, cwd), cwd, kills, timeoutMs: DESIGN_TIMEOUT_MS });
401
+ const version = !result.error && result.code === 0 ? pattern.exec(result.output)?.[1] : null;
402
+ if (version) return { ...row, version };
403
+ return { ...row, detail: result.error === 'Version check timed out.' ? 'The version check timed out.'
404
+ : result.error ? 'Could not read its version.' : 'Its version response was not recognized.' };
405
+ } catch { return { ...row, detail: 'Could not read its version.' }; }
406
+ }
407
+
408
+ export async function scanDesignTools({ env, cwd, platform = process.platform, resolve = resolveCommand, kills = null, send = () => {} } = {}) {
409
+ let folders = null;
410
+ // Read the registry at most once, and only when some tool is missing from PATH.
411
+ const registry = () => folders ??= platform === 'win32'
412
+ ? windowsInstallFolders({ env, cwd, kills }).catch(() => ({}))
413
+ : Promise.resolve({});
414
+ const installed = async (id) => findInstalled(designLocations(id, { platform, env, registry: (await registry())[id] || [] }), { platform });
415
+ const wslInterop = platform === 'linux' && isWsl({ env }) && wslInteropEnabled();
416
+ // Bounded concurrency: cold starts of large applications compete for the disk.
417
+ const queue = [...DESIGN_TOOLS];
418
+ await Promise.all([0, 1, 2].map(async () => {
419
+ for (let tool = queue.shift(); tool; tool = queue.shift()) {
420
+ send(await scanDesignTool(tool, { env, cwd, platform, resolve, installed, kills, wslInterop }));
421
+ }
422
+ }));
423
+ }
424
+
198
425
  // Windows cannot remove a process's current working directory, and a probe
199
426
  // killed at its deadline can still hold that directory. Cleanup must not
200
427
  // decide whether the scan succeeded.
@@ -235,13 +462,25 @@ if (process.argv[2] === '--scan-environment' && process.send) {
235
462
  process.chdir(cwd);
236
463
  const env = { ...process.env };
237
464
  process.send({ tools: detectTools({ env }) });
238
- // Two batches bound concurrency while giving each runtime its own result.
239
- for (let i = 0; i < RUNTIMES.length; i += 3) {
240
- await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
241
- const runtime = await scanRuntime(definition, { env, cwd, kills });
242
- process.send({ runtime });
243
- }));
244
- }
465
+ await Promise.all([
466
+ (async () => {
467
+ // Two batches bound concurrency while giving each runtime its own result.
468
+ for (let i = 0; i < RUNTIMES.length; i += 3) {
469
+ await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
470
+ const runtime = await scanRuntime(definition, { env, cwd, kills });
471
+ process.send({ runtime });
472
+ }));
473
+ }
474
+ })(),
475
+ scanDesignTools({ env, cwd, kills, send: (design) => process.send({ design }) }),
476
+ dockerInfo({ env, resolve: resolveCommand })
477
+ .catch(() => ({ status: 'failed', detail: 'The Docker check did not finish.' }))
478
+ .then((docker) => process.send({ docker })),
479
+ // System facts are the same for every scope, so only the manager asks.
480
+ process.argv.includes('--system') && systemInfo({ env, cwd, run: runProbe, kills })
481
+ .catch(() => null)
482
+ .then((system) => process.send({ system })),
483
+ ]);
245
484
  finished = true;
246
485
  } finally {
247
486
  await releaseScanDirectory(cwd, kills);
@@ -0,0 +1,205 @@
1
+ // System facts and the Docker engine, for the environment helper. Reads only:
2
+ // os, a few small files, one registry query on Windows, and one GET /version
3
+ // on a local Docker socket or pipe. No docker process is started and a remote
4
+ // engine is never contacted. Where systemd starts the engine on demand
5
+ // (docker.socket, podman.socket), opening its socket starts it.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import http from 'node:http';
10
+ import { createHash } from 'node:crypto';
11
+
12
+ const DOCKER_TIMEOUT_MS = 1500;
13
+ const DOCKER_LIMIT = 256 * 1024;
14
+ const REGISTRY_TIMEOUT_MS = 3000;
15
+ const LXSS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss';
16
+ // Linux's system sockets belong to the docker group; a rootless or Podman socket does not.
17
+ const GROUP_SOCKETS = ['/var/run/docker.sock', '/run/docker.sock'];
18
+
19
+ function value(env, name) {
20
+ return env[Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())];
21
+ }
22
+
23
+ function readText(file, read) {
24
+ try { return read(file, 'utf8'); } catch { return null; }
25
+ }
26
+
27
+ export function isWsl({ env = process.env, release = os.release() } = {}) {
28
+ return Boolean(value(env, 'WSL_DISTRO_NAME')) || /microsoft|wsl/i.test(release);
29
+ }
30
+
31
+ // WSL 1 kernels end in "-Microsoft"; WSL 2 kernels name WSL2. Newer WSL registers interop as WSLInterop-late.
32
+ export function wslInteropEnabled({ read = fs.readFileSync } = {}) {
33
+ return ['WSLInterop', 'WSLInterop-late'].some((name) => /^enabled/.test(readText(`/proc/sys/fs/binfmt_misc/${name}`, read) || ''));
34
+ }
35
+
36
+ function normalArch(name) {
37
+ const lower = String(name || '').toLowerCase();
38
+ if (['x86_64', 'amd64', 'x64'].includes(lower)) return 'x64';
39
+ if (['aarch64', 'arm64', 'aarch64_be'].includes(lower)) return 'arm64';
40
+ return lower || null;
41
+ }
42
+
43
+ function linuxName(read) {
44
+ for (const file of ['/etc/os-release', '/usr/lib/os-release']) {
45
+ const text = readText(file, read);
46
+ const match = text?.match(/^PRETTY_NAME=(["']?)(.+)\1\s*$/m);
47
+ if (match) return match[2];
48
+ }
49
+ return 'Linux';
50
+ }
51
+
52
+ function macName(read) {
53
+ const text = readText('/System/Library/CoreServices/SystemVersion.plist', read);
54
+ const version = text?.match(/<key>ProductVersion<\/key>\s*<string>([^<]+)<\/string>/)?.[1];
55
+ return version ? `macOS ${version}` : 'macOS';
56
+ }
57
+
58
+ // Windows lists WSL distributions under the user's Lxss key. Reading it does not start WSL.
59
+ export async function wslDistributions({ env, cwd, run, kills = null } = {}) {
60
+ const reg = path.win32.join(value(env, 'SystemRoot') || 'C:\\Windows', 'System32', 'reg.exe');
61
+ const result = await run(reg, ['query', LXSS, '/s'], { env, cwd, kills, timeoutMs: REGISTRY_TIMEOUT_MS });
62
+ if (result.error) return null;
63
+ if (result.code !== 0) return /unable to find/i.test(result.output) ? [] : null;
64
+ const fallback = result.output.match(/^\s+DefaultDistribution\s+REG_SZ\s+(\S+)/m)?.[1]?.toLowerCase();
65
+ const distros = new Map();
66
+ let key = null;
67
+ for (const line of result.output.split(/\r?\n/)) {
68
+ if (/^HKEY_/.test(line)) key = line.trim().split('\\').pop().toLowerCase();
69
+ const name = line.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/)?.[1];
70
+ const version = line.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)/i)?.[1];
71
+ if (!key || !key.startsWith('{')) continue;
72
+ const entry = distros.get(key) || { name: null, version: null, default: key === fallback };
73
+ if (name) entry.name = name;
74
+ if (version) entry.version = parseInt(version, 16);
75
+ distros.set(key, entry);
76
+ }
77
+ return [...distros.values()].filter((entry) => entry.name)
78
+ .sort((a, b) => Number(b.default) - Number(a.default) || a.name.localeCompare(b.name));
79
+ }
80
+
81
+ export async function systemInfo({
82
+ env = process.env, cwd, platform = process.platform, run, kills = null,
83
+ read = fs.readFileSync, release = os.release(), version = os.version(),
84
+ arch = process.arch, machine = os.machine(), cpus = os.cpus(), threads = os.availableParallelism(), memory = os.totalmem(),
85
+ } = {}) {
86
+ // Node says 'unknown' for a CPU it cannot name, such as many arm64 cores on Linux.
87
+ const model = cpus[0]?.model?.trim();
88
+ const info = {
89
+ os: null, osDetail: null, arch: normalArch(arch), hostArch: null,
90
+ cpu: { model: model && model !== 'unknown' ? model : null, threads }, memory, wsl: null, wslDistributions: null,
91
+ };
92
+ // Linux names the kernel's architecture, which can differ from Node's
93
+ // without emulation (a 32-bit build on a 64-bit kernel), so only Windows
94
+ // and macOS name a host architecture.
95
+ const host = platform === 'win32' || platform === 'darwin' ? normalArch(machine) : null;
96
+ if (host && host !== info.arch) info.hostArch = host;
97
+ if (platform === 'win32') {
98
+ info.os = version || 'Windows';
99
+ info.osDetail = `Version ${release}`;
100
+ try { info.wslDistributions = await wslDistributions({ env, cwd, run, kills }); } catch { /* the row is left out */ }
101
+ } else if (platform === 'darwin') {
102
+ info.os = macName(read);
103
+ info.osDetail = `Darwin ${release}`;
104
+ } else {
105
+ info.os = linuxName(read);
106
+ info.osDetail = `Kernel ${release}`;
107
+ if (isWsl({ env, release })) {
108
+ info.wsl = { version: /WSL2/i.test(release) ? 2 : 1, distro: value(env, 'WSL_DISTRO_NAME') || null, interop: wslInteropEnabled({ read }) };
109
+ }
110
+ }
111
+ return info;
112
+ }
113
+
114
+ // Which engine the docker command would use: DOCKER_HOST, then DOCKER_CONTEXT,
115
+ // then the config's current context, then the platform default.
116
+ export function dockerEndpoint({ env = process.env, platform = process.platform, read = fs.readFileSync } = {}) {
117
+ const host = value(env, 'DOCKER_HOST');
118
+ if (host) return { endpoint: host, context: null, source: 'DOCKER_HOST' };
119
+ const home = platform === 'win32' ? value(env, 'USERPROFILE') || os.homedir() : value(env, 'HOME') || os.homedir();
120
+ const join = platform === 'win32' ? path.win32.join : path.posix.join;
121
+ const configDir = value(env, 'DOCKER_CONFIG') || join(home, '.docker');
122
+ let context = value(env, 'DOCKER_CONTEXT');
123
+ let source = 'DOCKER_CONTEXT';
124
+ if (!context) {
125
+ try { context = JSON.parse(read(join(configDir, 'config.json'), 'utf8')).currentContext; } catch { /* no config */ }
126
+ source = 'config';
127
+ }
128
+ if (!context || context === 'default') {
129
+ return { endpoint: platform === 'win32' ? 'npipe:////./pipe/docker_engine' : 'unix:///var/run/docker.sock', context: 'default', source: 'default' };
130
+ }
131
+ const id = createHash('sha256').update(context).digest('hex');
132
+ try {
133
+ const meta = JSON.parse(read(join(configDir, 'contexts', 'meta', id, 'meta.json'), 'utf8'));
134
+ const endpoint = meta?.Endpoints?.docker?.Host;
135
+ if (typeof endpoint === 'string' && endpoint) return { endpoint, context, source };
136
+ } catch { /* reported below */ }
137
+ return { endpoint: null, context, source, error: `Docker context ${context} was not found.` };
138
+ }
139
+
140
+ // A local socket or named pipe, or null for an engine reached over the network.
141
+ export function localSocket(endpoint) {
142
+ if (/^unix:\/\//i.test(endpoint)) return endpoint.replace(/^unix:\/\//i, '') || null;
143
+ if (/^npipe:/i.test(endpoint)) {
144
+ const name = endpoint.replace(/^npipe:/i, '').replace(/^[/\\]+\.?[/\\]+pipe[/\\]+/i, '');
145
+ return name && !/[/\\]/.test(name) ? `\\\\.\\pipe\\${name}` : null;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ export function requestVersion(socketPath, { timeoutMs = DOCKER_TIMEOUT_MS, request = http.request } = {}) {
151
+ return new Promise((resolve) => {
152
+ let settled = false;
153
+ const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
154
+ try {
155
+ const req = request({ socketPath, path: '/version', method: 'GET', headers: { Host: 'docker' }, timeout: timeoutMs }, (res) => {
156
+ let body = '';
157
+ res.setEncoding('utf8');
158
+ res.on('data', (chunk) => {
159
+ body += chunk;
160
+ if (body.length > DOCKER_LIMIT) { req.destroy(); finish({ error: 'too_large' }); }
161
+ });
162
+ res.on('end', () => finish({ status: res.statusCode, body }));
163
+ res.on('error', () => finish({ error: 'read' }));
164
+ });
165
+ req.on('timeout', () => { req.destroy(); finish({ error: 'timeout' }); });
166
+ req.on('error', (err) => finish({ error: err.code || 'error' }));
167
+ req.end();
168
+ } catch (err) { finish({ error: err.code || 'error' }); }
169
+ });
170
+ }
171
+
172
+ export async function dockerInfo({ env = process.env, platform = process.platform, read = fs.readFileSync, resolve, query = requestVersion } = {}) {
173
+ const cli = resolve('docker', env, platform);
174
+ const target = dockerEndpoint({ env, platform, read });
175
+ const row = {
176
+ status: 'failed', version: null, platform: null, os: null, arch: null, wsl2: false,
177
+ context: target.context, endpoint: target.endpoint, cli, detail: null,
178
+ };
179
+ if (target.error) return { ...row, detail: target.error };
180
+ const socket = localSocket(target.endpoint);
181
+ if (!socket) return { ...row, status: 'remote', detail: 'This engine is reached over the network, so it is not checked.' };
182
+ const result = await query(socket);
183
+ if (result.error === 'EACCES' || result.error === 'EPERM') {
184
+ return { ...row, status: 'denied', detail: platform === 'win32'
185
+ ? 'The engine is running, but this user cannot open its pipe.'
186
+ : `The engine socket exists, but this user cannot open it.${platform === 'linux' && GROUP_SOCKETS.includes(socket) ? ' Adding the user to the docker group allows it.' : ''}` };
187
+ }
188
+ if (['ENOENT', 'ECONNREFUSED', 'ENOTSOCK'].includes(result.error)) {
189
+ return cli
190
+ ? { ...row, status: 'stopped', detail: 'The Docker engine is not running.' }
191
+ : { ...row, status: 'not_found', detail: 'No docker command on PATH and no engine answered.' };
192
+ }
193
+ if (result.error === 'timeout') return { ...row, detail: 'The engine did not answer in time.' };
194
+ if (result.error || result.status !== 200) return { ...row, detail: 'The engine did not give a usable answer.' };
195
+ let data;
196
+ try { data = JSON.parse(result.body); } catch { return { ...row, detail: 'The engine did not give a usable answer.' }; }
197
+ const version = typeof data?.Version === 'string' ? data.Version : null;
198
+ if (!version) return { ...row, detail: 'The engine did not give a usable answer.' };
199
+ const text = (item) => typeof item === 'string' && item ? item.slice(0, 200) : null;
200
+ return {
201
+ ...row, status: 'running', version,
202
+ platform: text(data.Platform?.Name), os: text(data.Os), arch: text(data.Arch),
203
+ wsl2: data.Os === 'linux' && /microsoft-standard-WSL2/i.test(data.KernelVersion || ''),
204
+ };
205
+ }
@@ -6,10 +6,10 @@ import { createHash } from 'node:crypto';
6
6
  import { EventEmitter } from 'node:events';
7
7
  import { fork } from 'node:child_process';
8
8
  import { killWindowsTree } from './command-resolver.mjs';
9
- import { RUNTIMES } from './environment-probe.mjs';
9
+ import { RUNTIMES, DESIGN_TOOLS } from './environment-probe.mjs';
10
10
  import { resolveProjectCwd } from './environment-pins.mjs';
11
11
 
12
- export const ENVIRONMENT_TIMEOUT_MS = 10000;
12
+ export const ENVIRONMENT_TIMEOUT_MS = 15000;
13
13
  export const LAUNCH_DETAIL = 'Launch PATH, profiles not applied. The selected shell is not consulted.';
14
14
  export const SESSION_DETAIL = 'Spawn PATH, before the shell startup files.';
15
15
  export const MULTIPLEXER_DETAIL = 'This session is tmux or herdr. Its environment is not the spawn record.';
@@ -20,7 +20,9 @@ const PATH_ENV = [
20
20
  'PATH', 'PATHEXT', 'SYSTEMROOT', 'HOME', 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
21
21
  'NVM_DIR', 'VOLTA_HOME', 'NODENV_ROOT', 'NODENV_VERSION', 'PYENV_ROOT', 'PYENV_VERSION',
22
22
  'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'MISE_CONFIG_DIR', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'CARGO_HOME',
23
- 'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME',
23
+ 'GOROOT', 'GOTOOLCHAIN', 'DOTNET_ROOT', 'R_HOME', 'DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG',
24
+ // Install folders searched for design tools that are not on PATH.
25
+ 'LOCALAPPDATA', 'PROGRAMFILES', 'PROGRAMW6432',
24
26
  ];
25
27
 
26
28
  function hostName() {
@@ -35,8 +37,41 @@ function cloneRow(row) {
35
37
  };
36
38
  }
37
39
 
38
- function pendingRuntimes() {
39
- return RUNTIMES.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
40
+ function pendingRows(list) {
41
+ return list.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null }));
42
+ }
43
+
44
+ const PENDING_DOCKER = { status: 'pending' };
45
+
46
+ function scanRun(extra = {}) {
47
+ return { child: null, timer: null, rows: new Map(), designs: new Map(), tools: [], docker: null, system: undefined, withSystem: false, ...extra };
48
+ }
49
+
50
+ // Records one helper message. Returns true when the helper reports it is done.
51
+ function takeMessage(run, message) {
52
+ if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
53
+ if (message?.design && DESIGN_TOOLS.some((tool) => tool.id === message.design.id)) run.designs.set(message.design.id, message.design);
54
+ if (Array.isArray(message?.tools)) run.tools = message.tools;
55
+ if (message?.docker && typeof message.docker === 'object') run.docker = message.docker;
56
+ if (run.withSystem && message && 'system' in message) run.system = message.system ?? null;
57
+ return Boolean(message?.done);
58
+ }
59
+
60
+ function scanComplete(run) {
61
+ return run.rows.size === RUNTIMES.length && run.designs.size === DESIGN_TOOLS.length
62
+ && Boolean(run.docker) && (!run.withSystem || run.system !== undefined);
63
+ }
64
+
65
+ // Every row has a fresh result; an unfinished check is a failure, never a stale success.
66
+ function scanRows(run, error) {
67
+ const failed = ({ id, label }) => ({ id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.' });
68
+ return {
69
+ runtimes: RUNTIMES.map((row) => run.rows.get(row.id) ?? failed(row)),
70
+ designTools: DESIGN_TOOLS.map((row) => run.designs.get(row.id) ?? failed(row)),
71
+ docker: run.docker ?? { status: 'failed', detail: error || 'The environment check did not finish.' },
72
+ tools: run.tools,
73
+ ...(run.withSystem ? { system: run.system ?? null } : {}),
74
+ };
40
75
  }
41
76
 
42
77
  export function probePathEnv(env) {
@@ -76,7 +111,10 @@ export class Environment extends EventEmitter {
76
111
  return {
77
112
  scope: 'manager', host: this.host, platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
78
113
  managerNode: { version: process.versions.node, path: process.execPath },
79
- runtimes: pendingRuntimes(),
114
+ runtimes: pendingRows(RUNTIMES),
115
+ designTools: pendingRows(DESIGN_TOOLS),
116
+ docker: PENDING_DOCKER,
117
+ system: null,
80
118
  tools: [],
81
119
  };
82
120
  }
@@ -93,7 +131,7 @@ export class Environment extends EventEmitter {
93
131
  }
94
132
  return publishLaunch ? this.launch : this.snapshot();
95
133
  }
96
- const run = { child: null, timer: null, rows: new Map(), tools: [], publishLaunch };
134
+ const run = scanRun({ publishLaunch, withSystem: true });
97
135
  this.run = run;
98
136
  this.value = { ...this.value, revision: this.value.revision + 1, refreshing: true, error: null };
99
137
  if (publishLaunch) this.armLaunch();
@@ -104,10 +142,7 @@ export class Environment extends EventEmitter {
104
142
  this.stopWorker(run.child);
105
143
  this.value = {
106
144
  ...this.value, revision: this.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
107
- runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
108
- id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
109
- }),
110
- tools: run.tools,
145
+ ...scanRows(run, error),
111
146
  };
112
147
  if (!this.closed) this.emit('updated', this.snapshot());
113
148
  if (run.publishLaunch) this.storeLaunch();
@@ -116,21 +151,18 @@ export class Environment extends EventEmitter {
116
151
  // A preload in NODE_OPTIONS must not execute inside a passive scan.
117
152
  const env = { ...this.env };
118
153
  for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
119
- run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment'], {
154
+ run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment', '--system'], {
120
155
  env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
121
156
  windowsHide: true, detached: process.platform !== 'win32',
122
157
  });
123
158
  run.child.on('message', (message) => {
124
- if (this.run !== run) return;
125
- if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
126
- if (Array.isArray(message?.tools)) run.tools = message.tools;
127
- if (message?.done) finish();
159
+ if (this.run === run && takeMessage(run, message)) finish();
128
160
  });
129
161
  run.child.once('error', () => finish('Could not start the environment check.'));
130
- // Let messages already queued win. Exit is a failed check only when a runtime never reported.
162
+ // Let messages already queued win. Exit is a failed check only when a row never reported.
131
163
  run.child.once('exit', () => setImmediate(() => {
132
164
  if (this.run !== run) return;
133
- finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
165
+ finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
134
166
  }));
135
167
  run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
136
168
  run.timer.unref();
@@ -146,6 +178,8 @@ export class Environment extends EventEmitter {
146
178
  revision: (previous?.revision ?? 0) + 1, refreshing: true, checkedAt: previous?.checkedAt ?? null, error: null,
147
179
  detail: LAUNCH_DETAIL,
148
180
  runtimes: (previous?.runtimes ?? this.value.runtimes).map(cloneRow),
181
+ designTools: (previous?.designTools ?? this.value.designTools).map(cloneRow),
182
+ docker: { ...(previous?.docker ?? this.value.docker) },
149
183
  tools: (previous?.tools ?? this.value.tools).map((tool) => ({ ...tool })),
150
184
  };
151
185
  if (!this.closed) this.emit('updated', this.launch);
@@ -157,6 +191,8 @@ export class Environment extends EventEmitter {
157
191
  revision: (this.launch?.revision ?? 0) + 1, refreshing: false, checkedAt: this.value.checkedAt, error: this.value.error,
158
192
  detail: LAUNCH_DETAIL,
159
193
  runtimes: this.value.runtimes.map(cloneRow),
194
+ designTools: this.value.designTools.map(cloneRow),
195
+ docker: { ...this.value.docker },
160
196
  tools: this.value.tools.map((tool) => ({ ...tool })),
161
197
  };
162
198
  if (!this.closed) this.emit('updated', this.launch);
@@ -287,7 +323,7 @@ export class Environment extends EventEmitter {
287
323
  return {
288
324
  scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
289
325
  availability: 'ok', detail: SESSION_DETAIL, revision: 0, refreshing: false, checkedAt: null, error: null,
290
- runtimes: pendingRuntimes(), tools: [],
326
+ runtimes: pendingRows(RUNTIMES), designTools: pendingRows(DESIGN_TOOLS), docker: PENDING_DOCKER, tools: [],
291
327
  };
292
328
  }
293
329
 
@@ -299,7 +335,7 @@ export class Environment extends EventEmitter {
299
335
  const value = {
300
336
  scope: 'session', host: this.host, platform: process.platform, sessionId: id, spawnCwd: found.spawnCwd,
301
337
  availability: 'unavailable', detail: MULTIPLEXER_DETAIL, revision: 1, refreshing: false,
302
- checkedAt: new Date().toISOString(), error: null, runtimes: [], tools: [],
338
+ checkedAt: new Date().toISOString(), error: null, runtimes: [], designTools: [], docker: null, tools: [],
303
339
  };
304
340
  this.sessions.set(key, { value, run: null });
305
341
  return value;
@@ -307,7 +343,7 @@ export class Environment extends EventEmitter {
307
343
 
308
344
  startSession(entry, found) {
309
345
  if (this.closed || entry.run) return;
310
- const run = { child: null, timer: null, rows: new Map(), tools: [] };
346
+ const run = scanRun();
311
347
  entry.run = run;
312
348
  entry.value = { ...entry.value, revision: entry.value.revision + 1, refreshing: true, error: null };
313
349
  const finish = (error = null) => {
@@ -317,10 +353,7 @@ export class Environment extends EventEmitter {
317
353
  this.stopWorker(run.child);
318
354
  entry.value = {
319
355
  ...entry.value, revision: entry.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
320
- runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
321
- id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
322
- }),
323
- tools: run.tools,
356
+ ...scanRows(run, error),
324
357
  };
325
358
  if (!this.closed) this.emit('updated', entry.value);
326
359
  };
@@ -332,15 +365,12 @@ export class Environment extends EventEmitter {
332
365
  windowsHide: true, detached: process.platform !== 'win32',
333
366
  });
334
367
  run.child.on('message', (message) => {
335
- if (entry.run !== run) return;
336
- if (message?.runtime && RUNTIMES.some((runtime) => runtime.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
337
- if (Array.isArray(message?.tools)) run.tools = message.tools;
338
- if (message?.done) finish();
368
+ if (entry.run === run && takeMessage(run, message)) finish();
339
369
  });
340
370
  run.child.once('error', () => finish('Could not start the environment check.'));
341
371
  run.child.once('exit', () => setImmediate(() => {
342
372
  if (entry.run !== run) return;
343
- finish(run.rows.size === RUNTIMES.length ? null : 'The environment check stopped before finishing.');
373
+ finish(scanComplete(run) ? null : 'The environment check stopped before finishing.');
344
374
  }));
345
375
  run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
346
376
  run.timer.unref();
@@ -14,6 +14,7 @@ import { NewsFeed } from './news.mjs';
14
14
  import { Changelog } from './changelog.mjs';
15
15
  import { GitHub } from './github.mjs';
16
16
  import { createManagerServer } from './server.mjs';
17
+ import { loadPty } from './pty.mjs';
17
18
  import { createNotesStore } from './notes.mjs';
18
19
  import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
19
20
  import { SelfUpdate } from './self-update.mjs';
@@ -48,7 +49,9 @@ const VERSION_REFRESH_MS = 60 * 60 * 1000;
48
49
  * `supervised`: started by the Linux boot service, so systemd starts the next manager after a restart.
49
50
  */
50
51
  export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE, github: githubOptions = {}, remoteAccess: remoteOptions = {}, autostart = null, boot, supervised = false } = {}) {
51
- // Validate before creating files, processes or timers, so a typo fails startup cleanly.
52
+ // Validate before creating files, processes or timers, so a typo, or a
53
+ // system node-pty cannot run on, fails startup cleanly.
54
+ loadPty();
52
55
  const remoteSettings = loadRemoteAccess(paths.remoteAccess);
53
56
  ensureDataDir();
54
57
  const token = loadOrCreateToken();
@@ -0,0 +1,112 @@
1
+ // node-pty, the library that runs each session's terminal. Its Linux builds
2
+ // in the package need glibc 2.28 or later: on an older glibc they do not
3
+ // load, and on musl (Alpine) the x64 one loads, then crashes the manager when
4
+ // the first session starts. So the C library is checked before node-pty is
5
+ // loaded, unless node-pty was compiled on this computer, which it then loads
6
+ // in place of the builds it comes with.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ /** node-pty's folder. Looked up when needed, so commands that never run a terminal do not depend on it. */
15
+ export function ptyDir() {
16
+ return path.dirname(require.resolve('node-pty/package.json'));
17
+ }
18
+
19
+ let glibc;
20
+
21
+ /** The glibc version this process runs on, such as '2.39'; null on another C library, such as musl. */
22
+ export function glibcVersion() {
23
+ if (glibc !== undefined) return glibc;
24
+ // Node.js reports it only on glibc, as npm reads it for a package's `libc`.
25
+ // Leaving out network details keeps the report from looking up host names.
26
+ const { report } = process;
27
+ const excludeNetwork = report.excludeNetwork;
28
+ report.excludeNetwork = true;
29
+ try {
30
+ glibc = report.getReport().header.glibcVersionRuntime ?? null;
31
+ } finally {
32
+ report.excludeNetwork = excludeNetwork;
33
+ }
34
+ return glibc;
35
+ }
36
+
37
+ /** True when node-pty's own builds run here: other systems than Linux, or glibc 2.28 or later. */
38
+ function bundledBuildsRun({ platform = process.platform, glibc: version } = {}) {
39
+ if (platform !== 'linux') return true;
40
+ if (version === undefined) version = glibcVersion();
41
+ if (!version) return false;
42
+ const [major, minor] = version.split('.').map(Number);
43
+ return major > 2 || (major === 2 && minor >= 28);
44
+ }
45
+
46
+ /** Whether node-pty in `dir` was compiled on this computer; node-pty loads such a build before the ones it comes with. */
47
+ export function ptyBuiltHere(dir = ptyDir()) {
48
+ return ['Release', 'Debug'].some((build) => fs.existsSync(path.join(dir, 'build', build, 'pty.node')));
49
+ }
50
+
51
+ const shellWord = (word) => (/^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll('\'', '\'\\\'\'')}'`);
52
+
53
+ /** The command that compiles node-pty in `dir` for this computer. */
54
+ export function ptyBuildCommand(dir = ptyDir()) {
55
+ return `cd ${shellWord(dir)} && npx --yes node-gyp rebuild`;
56
+ }
57
+
58
+ /**
59
+ * On a computer that has to compile node-pty itself: the command that does,
60
+ * and whether the files on disk hold such a build. Null where node-pty's own
61
+ * builds run. `platform`, `glibc` and `dir` stand in for this computer's in tests.
62
+ */
63
+ export function ptyBuild({ platform, glibc: version, dir } = {}) {
64
+ if (bundledBuildsRun({ platform, glibc: version })) return null;
65
+ dir ??= ptyDir();
66
+ return { command: ptyBuildCommand(dir), built: ptyBuiltHere(dir) };
67
+ }
68
+
69
+ /**
70
+ * Why node-pty cannot run here, as a message for the user, or null when it
71
+ * can. Takes the same stand-ins as ptyBuild().
72
+ */
73
+ export function ptyProblem(opts = {}) {
74
+ const build = ptyBuild(opts);
75
+ if (!build || build.built) return null;
76
+ const version = opts.glibc === undefined ? glibcVersion() : opts.glibc;
77
+ if (version) {
78
+ return `Agent Guild needs glibc 2.28 or later on Linux, for example Debian 10, Ubuntu 20.04 or RHEL 8. This system has glibc ${version}.`;
79
+ }
80
+ return [
81
+ 'Agent Guild\'s terminal library, node-pty, comes built for Linux with glibc 2.28 or later, and this system uses another C library, such as musl on Alpine.',
82
+ 'To build it here, install Python 3, make, g++ and the Linux headers (on Alpine: apk add python3 make g++ linux-headers), then run this, and again after each Agent Guild upgrade:',
83
+ ` ${build.command}`,
84
+ ].join('\n');
85
+ }
86
+
87
+ /**
88
+ * Why a manager started from the files on disk could not run a terminal, or
89
+ * null. A manager running where node-pty's own builds do not means node-pty
90
+ * was compiled here, so the message is how to compile it again, on any C
91
+ * library. Takes the same stand-ins as ptyBuild().
92
+ */
93
+ export function ptyRestartProblem(opts = {}) {
94
+ const build = ptyBuild(opts);
95
+ if (!build || build.built) return null;
96
+ return [
97
+ 'Agent Guild\'s terminal library, node-pty, is built on this computer, and the files on disk no longer hold that build, as after an upgrade. Build it again, then restart:',
98
+ ` ${build.command}`,
99
+ 'The manager was not restarted, and its sessions keep running.',
100
+ ].join('\n');
101
+ }
102
+
103
+ let pty = null;
104
+
105
+ /** node-pty, loaded on first use. Throws with ptyProblem()'s message where it cannot run. */
106
+ export function loadPty() {
107
+ if (pty) return pty;
108
+ const problem = ptyProblem();
109
+ if (problem) throw new Error(problem);
110
+ pty = require('node-pty');
111
+ return pty;
112
+ }
@@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events';
7
7
  import { compareVersions, fetchManifest } from './versions.mjs';
8
8
  import { formatCommand } from './install-channels.mjs';
9
9
  import { buildSpawnSpec } from './command-resolver.mjs';
10
+ import { ptyBuild } from './pty.mjs';
10
11
 
11
12
  const CHECK_TTL_MS = 60 * 60 * 1000;
12
13
  const FAILED_CHECK_TTL_MS = 5 * 60 * 1000;
@@ -39,13 +40,15 @@ export class SelfUpdate extends EventEmitter {
39
40
  * @param {string} opts.version the running version
40
41
  * @param {string|null} [opts.packageFile] the package.json the manager runs from; read again after an install
41
42
  * @param {import('./providers.mjs').ProviderRegistry} opts.registry for the registry URL, npm, and fetch
43
+ * @param {() => ({ command: string, built: boolean }|null)} [opts.ptyBuild] node-pty's build on this computer; a stand-in in tests
42
44
  */
43
- constructor({ pkg, version, packageFile = null, registry }) {
45
+ constructor({ pkg, version, packageFile = null, registry, ptyBuild: readPtyBuild = ptyBuild }) {
44
46
  super();
45
47
  this.pkg = pkg;
46
48
  this.version = version;
47
49
  this.packageFile = packageFile;
48
50
  this.registry = registry;
51
+ this.readPtyBuild = readPtyBuild;
49
52
  this.latest = null;
50
53
  this.error = null;
51
54
  this.checkedAt = 0;
@@ -139,7 +142,7 @@ export class SelfUpdate extends EventEmitter {
139
142
  if (this.installing) {
140
143
  return {
141
144
  version: this.version, latestVersion: this.latest, available: false, command: null, guidance: null,
142
- pendingVersion: null, installing: true, lastInstall: null,
145
+ pendingVersion: null, installing: true, lastInstall: null, ptyBuild: null,
143
146
  };
144
147
  }
145
148
  const installed = this.installedVersion();
@@ -155,6 +158,8 @@ export class SelfUpdate extends EventEmitter {
155
158
  pendingVersion: this.pendingVersion(installed),
156
159
  installing: false,
157
160
  lastInstall: this.lastInstall,
161
+ // Where node-pty has to be compiled here, an upgrade replaces that build.
162
+ ptyBuild: this.readPtyBuild(),
158
163
  };
159
164
  }
160
165
 
@@ -14,6 +14,7 @@ import { createFolderBrowser } from './folder-browser.mjs';
14
14
  import { normalizeAccess } from './access-policy.mjs';
15
15
  import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
16
16
  import { MODES as AUTOSTART_MODES } from './autostart.mjs';
17
+ import { ptyRestartProblem } from './pty.mjs';
17
18
 
18
19
  const require = createRequire(import.meta.url);
19
20
  const API = '/api/v1';
@@ -154,6 +155,8 @@ export function createManagerServer({
154
155
  launcher = null,
155
156
  /** @type {(opts: { restart: boolean }) => void} */
156
157
  onShutdownRequest = () => {},
158
+ /** Why a manager started from the files on disk could not run a terminal, or null; a stand-in in tests. */
159
+ nextManagerProblem = ptyRestartProblem,
157
160
  }) {
158
161
  let access = normalizeAccess({ hosts: extraHosts, origins: extraOrigins });
159
162
  let policyVersion = 0;
@@ -429,6 +432,11 @@ export function createManagerServer({
429
432
  // so a client must say `force` while any of those is running. The same
430
433
  // guard serves every front end.
431
434
  const body = await readJsonBody(req);
435
+ // The next manager runs the files on disk. Where node-pty has to be
436
+ // built on this computer and an upgrade replaced that build, it would
437
+ // not start, so this one keeps running and says what to do first.
438
+ const problem = body.restart === true ? nextManagerProblem() : null;
439
+ if (problem) throw new HttpError(409, problem, 'pty_unavailable');
432
440
  const running = manager.runningCount();
433
441
  if (running > 0 && body.force !== true) {
434
442
  const err = new HttpError(409, `${running} session(s) are running; stopping the manager ends them`, 'sessions_running');
@@ -4,10 +4,10 @@
4
4
 
5
5
  import { EventEmitter } from 'node:events';
6
6
  import crypto from 'node:crypto';
7
- import pty from 'node-pty';
8
7
  import headless from '@xterm/headless';
9
8
  import serializeAddon from '@xterm/addon-serialize';
10
9
  import { killWindowsTree } from './command-resolver.mjs';
10
+ import { loadPty } from './pty.mjs';
11
11
 
12
12
  const { Terminal } = headless;
13
13
  const { SerializeAddon } = serializeAddon;
@@ -175,7 +175,7 @@ export class Session extends EventEmitter {
175
175
  }
176
176
 
177
177
  _start(spawnSpec, cwd = this.cwd) {
178
- const proc = pty.spawn(spawnSpec.file, spawnSpec.args, {
178
+ const proc = loadPty().spawn(spawnSpec.file, spawnSpec.args, {
179
179
  name: 'xterm-256color',
180
180
  cols: this.cols,
181
181
  rows: this.rows,
package/web/app.js CHANGED
@@ -879,26 +879,58 @@ function moveDockTab(e) {
879
879
 
880
880
  // ---- upgrading the manager ------------------------------------------------
881
881
 
882
+ /**
883
+ * On a computer that compiles node-pty, the terminal library, itself: the
884
+ * build an installed upgrade replaced, which the manager will not restart
885
+ * without. Null otherwise.
886
+ */
887
+ function ptyRebuild(u = state.upgrade) {
888
+ const build = u?.ptyBuild;
889
+ return u?.pendingVersion && build && !build.built ? build : null;
890
+ }
891
+
892
+ /**
893
+ * While an upgrade waits for node-pty to be compiled again, read the build
894
+ * once more when the user comes back to the page, from the terminal where
895
+ * they may have run the command. The manager sends no event for it.
896
+ */
897
+ async function recheckPtyBuild() {
898
+ if (!state.connected || !ptyRebuild()) return;
899
+ try {
900
+ setUpgrade((await api('GET', '/info')).upgrade);
901
+ } catch { /* the next hello brings it */ }
902
+ }
903
+
904
+ /** A message about compiling node-pty here, with a button that copies the command. */
905
+ function ptyBuildToast(message, build) {
906
+ toast(message, 20000, { label: 'Copy command', run: () => copyText(build.command, 'the command') });
907
+ }
908
+
882
909
  function renderUpgrade() {
883
910
  const u = state.upgrade;
884
911
  const button = $('upgrade');
885
912
  const note = $('upgrade-note');
886
913
  const offer = Boolean(state.connected && u?.available && u.command);
914
+ const build = u?.ptyBuild;
887
915
  button.hidden = !offer;
888
916
  if (offer) {
889
917
  button.textContent = `Upgrade to ${u.latestVersion}`;
890
- button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.`;
918
+ button.title = `Run "${u.command}" in a session. Sessions keep running; the new version is used once the manager is restarted.` +
919
+ (build ? ` This computer builds node-pty, the terminal library, itself: after the upgrade, run "${build.command}" in a terminal before restarting.` : '');
891
920
  }
892
921
  // A newer version on disk is used by the next manager, so the restart
893
922
  // button becomes the way to pick it up.
894
923
  const restart = $('restart-manager');
895
924
  const pending = u?.pendingVersion;
925
+ const rebuild = ptyRebuild(u);
896
926
  restart.classList.toggle('pending', Boolean(pending));
897
927
  $('manager').classList.toggle('pending', Boolean(pending) && state.restartable);
898
928
  restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
899
- restart.title = pending
900
- ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
901
- : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
929
+ restart.title = rebuild
930
+ ? `Agent Guild ${pending} is installed, and node-pty, the terminal library, has to be built for this computer again before the manager can restart. Run "${rebuild.command}" in a terminal first.`
931
+ : pending
932
+ ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
933
+ : 'Stop the session manager and start it again. This ends every session; this page reconnects by itself.';
902
934
  const panelUpgrade = $('changelog-upgrade');
903
935
  panelUpgrade.hidden = button.hidden;
904
936
  panelUpgrade.textContent = button.textContent;
@@ -914,6 +946,9 @@ function renderUpgrade() {
914
946
  if (u?.installing) {
915
947
  text = `Upgrading${u.latestVersion ? ` to v${u.latestVersion}` : ''}…`;
916
948
  title = 'npm is running in a session. Keep the manager running until it finishes.';
949
+ } else if (rebuild) {
950
+ text = `v${pending} installed · build node-pty before restarting`;
951
+ title = `This computer builds node-pty, the terminal library, itself, and the upgrade replaced that build. Run "${rebuild.command}" in a terminal, then restart.`;
917
952
  } else if (pending && !state.restartable) {
918
953
  text = `v${pending} installed · run "agent-guild restart" to use it`;
919
954
  title = `Agent Guild ${pending} is installed, but this manager is still ${u.version} and cannot restart itself. Run "agent-guild restart" in a terminal when your sessions are done; this page reconnects by itself.`;
@@ -926,6 +961,9 @@ function renderUpgrade() {
926
961
  } else if (u?.available && !u.command) {
927
962
  text = `v${u.latestVersion} available`;
928
963
  title = u.guidance || '';
964
+ } else if (offer && build) {
965
+ text = 'Build node-pty again after upgrading';
966
+ title = `This computer builds node-pty, the terminal library, itself, and an upgrade replaces that build. After upgrading, run "${build.command}" in a terminal before restarting.`;
929
967
  }
930
968
  note.hidden = !state.connected || !text;
931
969
  note.textContent = text;
@@ -940,7 +978,10 @@ function setUpgrade(upgrade, baseline = false) {
940
978
  renderVersion();
941
979
  if ($('changelog').open) renderChangelog();
942
980
  const pending = state.upgrade?.pendingVersion;
943
- if (pending && pending !== before?.pendingVersion) {
981
+ const rebuild = ptyRebuild();
982
+ if (pending && pending !== before?.pendingVersion && rebuild) {
983
+ ptyBuildToast(`Agent Guild ${pending} is installed. Before restarting, build node-pty for this computer again: run "${rebuild.command}" in a terminal.`, rebuild);
984
+ } else if (pending && pending !== before?.pendingVersion) {
944
985
  toast(state.restartable
945
986
  ? `Agent Guild ${pending} is installed. Use "Restart to use v${pending}" in the top bar when your sessions are done.`
946
987
  : `Agent Guild ${pending} is installed. Run "agent-guild restart" in a terminal when your sessions are done.`, 10000);
@@ -5634,6 +5675,10 @@ async function stopManager({ force = false, restart = false } = {}) {
5634
5675
  }
5635
5676
  return;
5636
5677
  }
5678
+ const build = state.upgrade?.ptyBuild;
5679
+ if (err.code === 'pty_unavailable' && build) {
5680
+ return ptyBuildToast(`The manager was not restarted, and its sessions keep running. First build node-pty, the terminal library, for this computer: run "${build.command}" in a terminal.`, build);
5681
+ }
5637
5682
  toast(err.message, 8000);
5638
5683
  } finally {
5639
5684
  for (const button of buttons) button.disabled = false;
@@ -6123,10 +6168,12 @@ document.addEventListener('visibilitychange', () => {
6123
6168
  flushNotes();
6124
6169
  }
6125
6170
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
6171
+ if (document.visibilityState === 'visible') recheckPtyBuild();
6126
6172
  if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
6127
6173
  else scheduleRuns();
6128
6174
  if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
6129
6175
  });
6176
+ addEventListener('focus', recheckPtyBuild);
6130
6177
  addEventListener('pagehide', () => {
6131
6178
  flushNotes();
6132
6179
  state.pageAway = true;
@@ -1,4 +1,6 @@
1
1
  const STATES = { pending: 'Not checked', not_found: 'Not found', unavailable: 'Runtime unavailable', failed: 'Probe failed' };
2
+ const DESIGN_STATES = { pending: 'Not checked', not_found: 'Not found', on_path: 'On PATH', not_on_path: 'Not on PATH', failed: 'Probe failed' };
3
+ const DOCKER_STATES = { pending: 'Not checked', running: 'Running', stopped: 'Not running', denied: 'No access', remote: 'Not checked', not_found: 'Not found', failed: 'Probe failed' };
2
4
  const PIN_STATES = { configured: 'Configured', unreadable: 'Unreadable', invalid: 'Invalid' };
3
5
  const SCOPES = [
4
6
  ['manager', 'Manager', 'Manager environment'],
@@ -9,6 +11,37 @@ const SCOPES = [
9
11
 
10
12
  export function runtimeValue(row) { return row.status === 'ok' ? row.version : STATES[row.status] || 'Probe failed'; }
11
13
 
14
+ export function designValue(row) {
15
+ const state = DESIGN_STATES[row.status] || DESIGN_STATES.failed;
16
+ if (!row.version) return state;
17
+ return row.status === 'not_on_path' ? `${row.version} · Not on PATH` : row.version;
18
+ }
19
+
20
+ export function formatMemory(bytes) { return `${(bytes / 1024 ** 3).toFixed(1)} GB`; }
21
+
22
+ // Label, value and detail lines for each System row.
23
+ export function systemRows(system) {
24
+ const rows = [];
25
+ const wsl = system.wsl;
26
+ rows.push({ label: 'OS', value: system.os, details: [wsl && `WSL ${wsl.version}${wsl.distro ? ` · ${wsl.distro}` : ''}`, system.osDetail] });
27
+ rows.push({ label: 'Architecture', value: system.hostArch ? `${system.arch} · emulated on ${system.hostArch}` : system.arch, details: [] });
28
+ rows.push({ label: 'CPU', value: `${system.cpu.threads} threads`, details: [system.cpu.model] });
29
+ rows.push({ label: 'Memory', value: formatMemory(system.memory), details: [wsl && 'Memory of the WSL virtual machine, set in .wslconfig. Windows can have more.'] });
30
+ if (wsl) rows.push({ label: 'Windows interop', value: wsl.interop ? 'On' : 'Off', details: [!wsl.interop && 'Agents cannot run Windows programs from this distribution.'] });
31
+ if (Array.isArray(system.wslDistributions)) {
32
+ const list = system.wslDistributions;
33
+ rows.push({
34
+ label: 'WSL', value: !list.length ? 'None installed' : list.length === 1 ? '1 distribution' : `${list.length} distributions`,
35
+ details: list.map((item) => `${item.name}${item.version ? ` · WSL ${item.version}` : ''}${item.default ? ' · default' : ''}`),
36
+ });
37
+ }
38
+ return rows.map((row) => ({ ...row, details: row.details.filter(Boolean) }));
39
+ }
40
+
41
+ export function dockerValue(docker) {
42
+ return docker.status === 'running' && docker.version ? docker.version : DOCKER_STATES[docker.status] || DOCKER_STATES.failed;
43
+ }
44
+
12
45
  export function createEnvironmentUI({
13
46
  api, onAuthError, isAuthError = () => false, document = globalThis.document,
14
47
  workingFolder = () => '', sessions = () => [],
@@ -36,11 +69,16 @@ export function createEnvironmentUI({
36
69
  : !manager ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
37
70
  }
38
71
 
39
- function rowElement(row) {
72
+ function rowHead(label, value) {
40
73
  const node = element('section', undefined, 'environment-row');
41
74
  const head = element('div', undefined, 'environment-row-head');
42
- head.append(element('strong', row.label), element('span', runtimeValue(row)));
75
+ head.append(element('strong', label), element('span', value));
43
76
  node.append(head);
77
+ return node;
78
+ }
79
+
80
+ function rowElement(row) {
81
+ const node = rowHead(row.label, runtimeValue(row));
44
82
  if (row.command) node.append(element('p', `Command: ${row.command}`, 'environment-detail'));
45
83
  if (row.path) node.append(element('code', row.path, 'environment-path'));
46
84
  const detail = row.detail || (row.status === 'not_found' ? 'No executable was found on the manager’s PATH.' : '');
@@ -54,11 +92,37 @@ export function createEnvironmentUI({
54
92
  return node;
55
93
  }
56
94
 
95
+ function designElement(row) {
96
+ const node = rowHead(row.label, designValue(row));
97
+ if (row.command) node.append(element('p', `Command: ${row.command}`, 'environment-detail'));
98
+ if (row.path) node.append(element('code', row.path, 'environment-path'));
99
+ if (row.status === 'not_on_path') node.append(element('p', 'Installed outside PATH. Agents can run it once its folder is on PATH.', 'environment-detail'));
100
+ if (row.detail) node.append(element('p', row.detail, 'environment-detail'));
101
+ return node;
102
+ }
103
+
104
+ function systemElement(row) {
105
+ const node = rowHead(row.label, row.value);
106
+ for (const detail of row.details) node.append(element('p', detail, 'environment-detail'));
107
+ return node;
108
+ }
109
+
110
+ function dockerElement(docker) {
111
+ const node = rowHead('Docker Engine', dockerValue(docker));
112
+ if (docker.platform) node.append(element('p', docker.platform, 'environment-detail'));
113
+ if (docker.os) node.append(element('p', `${docker.os}${docker.arch ? `/${docker.arch}` : ''}${docker.wsl2 ? ' · runs in WSL 2' : ''}`, 'environment-detail'));
114
+ if (docker.context) node.append(element('p', `Context: ${docker.context}`, 'environment-detail'));
115
+ if (docker.endpoint) node.append(element('code', docker.endpoint, 'environment-path'));
116
+ if (docker.detail) node.append(element('p', docker.detail, 'environment-detail'));
117
+ if (docker.status !== 'pending') {
118
+ node.append(element('p', docker.cli ? 'Command: docker' : 'The docker command is not on PATH.', 'environment-detail'));
119
+ if (docker.cli) node.append(element('code', docker.cli, 'environment-path'));
120
+ }
121
+ return node;
122
+ }
123
+
57
124
  function pinElement(row) {
58
- const node = element('section', undefined, 'environment-row');
59
- const head = element('div', undefined, 'environment-row-head');
60
- head.append(element('strong', row.label), element('span', PIN_STATES[row.status] || 'Invalid'));
61
- node.append(head);
125
+ const node = rowHead(row.label, PIN_STATES[row.status] || 'Invalid');
62
126
  node.append(element('p', row.source, 'environment-detail'));
63
127
  if (row.version) node.append(element('code', row.version, 'environment-path'));
64
128
  if (row.detail) node.append(element('p', row.detail, 'environment-detail'));
@@ -143,6 +207,18 @@ export function createEnvironmentUI({
143
207
  $('environment-tools-note').hidden = !showTools;
144
208
  $('environment-tools').hidden = !showTools;
145
209
  $('environment-tools-empty').hidden = !showTools;
210
+ $('environment-runtimes-heading').textContent = scope === 'project' ? 'Version pins' : 'Runtimes';
211
+ $('environment-runtimes-heading').hidden = scope === 'session' && data?.availability === 'unavailable';
212
+ for (const id of ['environment-design-heading', 'environment-design-note', 'environment-design']) $(id).hidden = !showTools;
213
+ $('environment-design').replaceChildren(...(showTools ? data?.designTools || [] : []).map(designElement));
214
+ const docker = showTools && data?.docker ? data.docker : null;
215
+ $('environment-docker-heading').hidden = !docker;
216
+ $('environment-docker').hidden = !docker;
217
+ $('environment-docker').replaceChildren(...(docker ? [dockerElement(docker)] : []));
218
+ const system = scope === 'manager' && data?.system ? data.system : null;
219
+ $('environment-system-heading').hidden = !system;
220
+ $('environment-system').hidden = !system;
221
+ $('environment-system').replaceChildren(...(system ? systemRows(system).map(systemElement) : []));
146
222
  if (scope === 'project' && data && folder === projectFolder) {
147
223
  $('environment-runtimes').hidden = true;
148
224
  $('environment-runtimes').replaceChildren();
@@ -157,8 +233,8 @@ export function createEnvironmentUI({
157
233
  }
158
234
  if (showTools) {
159
235
  $('environment-tools').replaceChildren(...tools.map((tool) => {
160
- const node = element('section', undefined, 'environment-row');
161
- node.append(element('strong', `${tool.label} · Detected`), element('code', tool.path, 'environment-path'));
236
+ const node = rowHead(tool.label, 'Detected');
237
+ node.append(element('code', tool.path, 'environment-path'));
162
238
  return node;
163
239
  }));
164
240
  $('environment-tools-empty').hidden = Boolean(tools.length);
package/web/index.html CHANGED
@@ -400,12 +400,20 @@
400
400
  <select id="environment-session" hidden></select>
401
401
  <p id="environment-status" class="environment-detail" role="status"></p>
402
402
  <button id="environment-refresh" class="btn" type="button">Refresh</button>
403
+ <h3 id="environment-system-heading" hidden>System</h3>
404
+ <div id="environment-system" hidden></div>
405
+ <h3 id="environment-runtimes-heading">Runtimes</h3>
403
406
  <div id="environment-runtimes"></div>
404
407
  <div id="environment-pins" hidden></div>
405
408
  <h3 id="environment-tools-heading">Detected tools</h3>
406
409
  <p id="environment-tools-note" class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
407
410
  <div id="environment-tools"></div>
408
411
  <p id="environment-tools-empty" class="environment-detail"></p>
412
+ <h3 id="environment-design-heading">Design tools</h3>
413
+ <p id="environment-design-note" class="environment-detail">Agents can run a tool only when it is on PATH. A version is read only from the tool's own program.</p>
414
+ <div id="environment-design"></div>
415
+ <h3 id="environment-docker-heading">Docker</h3>
416
+ <div id="environment-docker"></div>
409
417
  <p id="environment-manager-node" class="environment-detail"></p>
410
418
  </div>
411
419
  </dialog>