@oddessentials/agent-guild 0.30.0 → 0.31.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
@@ -86,6 +86,30 @@ Prompt are also offered when found. On macOS and Linux it follows your login
86
86
  shell. Pick the default chip to clear a saved choice and follow the default
87
87
  again. The choice applies to new sessions.
88
88
 
89
+ **See the manager environment.** The Shell card summarizes detected Node.js,
90
+ Python, Go, .NET SDK, R and Rust versions. **Environment details** shows the
91
+ resolved paths, detection results, .NET runtimes and detected nvm/NVM for
92
+ Windows, vfox, uv and pnpm launchers. Tools are listed by presence; their
93
+ activation is not inferred. This describes the computer running Agent Guild
94
+ and its manager environment, including when viewed remotely. Selected shells,
95
+ projects and existing sessions may use different versions.
96
+
97
+ Python always uses `python` when it is found on the manager's PATH; otherwise
98
+ it uses `python3`. A broken `python` remains the primary result, with a
99
+ different `python3` shown separately. **Not found** means no command was
100
+ resolved, **Runtime unavailable** means a launcher was found but no runtime
101
+ could safely be reported, and **Probe failed** means inspection or the version
102
+ check failed. Missing optional runtimes never prevent starting a shell.
103
+
104
+ Discovery runs on first viewing and again on **Refresh**, in an isolated
105
+ helper with a neutral temporary working directory. It does not load shell
106
+ profiles, activate tools, download runtimes or modify installations. Unknown
107
+ script/shim launchers and Windows execution aliases are left unverified.
108
+ Refresh checks the manager's current environment; it does not reload its
109
+ PATH from a shell. Existing results remain visible during refresh. Go reports
110
+ the local bundled toolchain with automatic switching disabled, and the Node
111
+ version running Agent Guild is also shown in details.
112
+
89
113
  **Work inside tmux or herdr.** When installed, the Shell card also offers
90
114
  tmux (3.2 or later, on macOS and Linux) and herdr. A tmux card runs a tmux
91
115
  session of its own, with your own configuration, and tools in it report to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.30.0",
3
+ "version": "0.31.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": {
@@ -0,0 +1,189 @@
1
+ // Passive discovery only. This module is also the isolated helper entry point.
2
+ // Never source profiles, invoke package managers, or use a project directory.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { resolveCommand, killWindowsTree } from './command-resolver.mjs';
8
+
9
+ export const RUNTIMES = [
10
+ { id: 'node', label: 'Node.js', command: 'node', args: ['--version'], pattern: /^v(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
11
+ { id: 'python', label: 'Python', command: 'python', args: ['--version'], pattern: /^Python (\d+\.\d+\.\d+(?:[\w.+-]*)?)/m },
12
+ { id: 'go', label: 'Go', command: 'go', args: ['version'], pattern: /^go version go(\d+\.\d+(?:\.\d+)?(?:[\w.-]*)?)(?:\s|$)/m },
13
+ { id: 'dotnet', label: '.NET SDK', command: 'dotnet', args: ['--version'], pattern: /^(\d+\.\d+\.\d+(?:-[\w.-]+)?)\s*$/m },
14
+ { id: 'r', label: 'R', command: 'R', args: ['--version'], pattern: /^R version (\d+\.\d+\.\d+(?:[\w.-]*)?)/m },
15
+ { id: 'rust', label: 'Rust', command: 'rustc', args: ['--version'], pattern: /^rustc (\d+\.\d+\.\d+(?:-[\w.-]+)?)(?:\s|$)/m },
16
+ ];
17
+ const LIMIT = 32 * 1024;
18
+ const TIMEOUT_MS = 1800;
19
+
20
+ function readHead(file) {
21
+ const fd = fs.openSync(file, 'r');
22
+ try {
23
+ const bytes = Buffer.alloc(LIMIT);
24
+ return bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0));
25
+ } finally { fs.closeSync(fd); }
26
+ }
27
+
28
+ function value(env, name) {
29
+ return env[Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())];
30
+ }
31
+
32
+ export function probeEnv(env) {
33
+ const out = { ...env };
34
+ // Overrides apply only to the helper's probes, never the manager or sessions.
35
+ const overrides = {
36
+ NODE_OPTIONS: '', GOTOOLCHAIN: 'local', GOENV: 'off', GOWORK: 'off',
37
+ RUSTUP_AUTO_INSTALL: '0', PYTHON_MANAGER_AUTOMATIC_INSTALL: 'false',
38
+ DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1',
39
+ DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE: 'true', DOTNET_NOLOGO: '1',
40
+ COREPACK_ENABLE_NETWORK: '0', UV_OFFLINE: '1', UV_PYTHON_DOWNLOADS: 'never',
41
+ };
42
+ for (const key of Object.keys(out)) if (Object.hasOwn(overrides, key.toUpperCase())) delete out[key];
43
+ return { ...out, ...overrides };
44
+ }
45
+
46
+ // Do not execute unknown script shims. Most version managers link to native
47
+ // runtimes; following the link makes their normal installations work directly.
48
+ // R's Unix launcher is itself an official shell script and exits on --version.
49
+ export function passiveExecutable(file, id, { platform = process.platform, env = process.env, realpath = fs.realpathSync, read = readHead } = {}) {
50
+ const real = realpath(file);
51
+ const normalized = real.replaceAll('\\', '/').toLowerCase();
52
+ if (/\/(?:shims|\.nodejs)\//.test(normalized) || /\/(?:mise|asdf|volta)(?:\.exe)?$/.test(normalized)) {
53
+ return { error: 'A version-manager shim was found. Its runtime cannot be checked without activating the manager.' };
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.' };
57
+ }
58
+ const head = read(real);
59
+ const binary = head[0] === 0x4d && head[1] === 0x5a // PE
60
+ || head[0] === 0x7f && head.subarray(1, 4).toString() === 'ELF'
61
+ || ['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca', 'cafebabf', 'bfbafeca'].includes(head.subarray(0, 4).toString('hex'));
62
+ if (binary) {
63
+ // rustup dispatches using argv[0]; preserve its rustc proxy's name.
64
+ return { file: id === 'rust' && /\/rustup(?:\.exe)?$/.test(normalized) ? file : real };
65
+ }
66
+ const text = head.toString('utf8');
67
+ if (id === 'r' && platform !== 'win32' && text.startsWith('#!') && /R_HOME_DIR=/.test(text) && /--version/.test(text)) return { file: real };
68
+ return { error: 'A script launcher was found. Passive detection does not execute unrecognized wrappers.' };
69
+ }
70
+
71
+ // The helper has a second, independent overall deadline in Environment. A
72
+ // timeout here resolves immediately, even if a descendant retains stdout.
73
+ export function runProbe(file, args, { env, cwd, timeoutMs = TIMEOUT_MS, spawnProcess = spawn } = {}) {
74
+ return new Promise((resolve) => {
75
+ let child, timer, size = 0, output = '', settled = false;
76
+ const stop = () => {
77
+ 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();
81
+ };
82
+ const finish = (result, kill = false) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+ if (kill) stop();
87
+ resolve({ output, ...result });
88
+ };
89
+ try {
90
+ child = spawnProcess(file, args, { env, cwd, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
91
+ timer = setTimeout(() => finish({ error: 'Version check timed out.' }, true), timeoutMs);
92
+ for (const stream of [child.stdout, child.stderr]) stream.on('data', (chunk) => {
93
+ size += chunk.length;
94
+ if (size > LIMIT) return finish({ error: 'Version check produced too much output.' }, true);
95
+ output += chunk.toString();
96
+ });
97
+ child.on('error', () => finish({ error: 'Could not run the resolved executable.' }, true));
98
+ child.on('close', (code) => finish({ code }));
99
+ } catch { finish({ error: 'Could not run the resolved executable.' }, true); }
100
+ });
101
+ }
102
+
103
+ const unavailable = {
104
+ node: /no (?:default |installed )?(?:node|version)|version .*not installed/i,
105
+ python: /no (?:suitable |installed )?(?:python|runtime)|python was not found|no runtimes? (?:are )?installed/i,
106
+ go: /cannot find GOROOT|toolchain .*not available/i,
107
+ dotnet: /no .NET SDKs were found|compatible .NET SDK was not found|SDK .*not found/i,
108
+ rust: /no default is configured|toolchain .*not installed|could not choose a version|toolchain .*is not installable/i,
109
+ };
110
+
111
+ export async function scanRuntime(definition, { env, cwd, platform = process.platform, resolve = resolveCommand, inspect = passiveExecutable, run = runProbe } = {}) {
112
+ const { id, label, command, args, pattern } = definition;
113
+ const base = { id, label, status: 'not_found', version: null, path: null, command, detail: null };
114
+ const one = async (name) => {
115
+ const file = resolve(name, env, platform);
116
+ if (!file) return { ...base, command: name };
117
+ const row = { ...base, command: name, path: file };
118
+ try {
119
+ const executable = inspect(file, id, { platform, env });
120
+ 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 });
122
+ if (result.error) return { ...row, status: 'failed', detail: result.error };
123
+ const version = result.code === 0 ? pattern.exec(result.output)?.[1] : null;
124
+ if (version) return { ...row, status: 'ok', version };
125
+ const missing = unavailable[id]?.test(result.output);
126
+ return { ...row, status: missing ? 'unavailable' : 'failed', detail: missing
127
+ ? 'The launcher was found, but it could not provide a local runtime.'
128
+ : result.code === 0 ? 'The version response was not recognized.' : 'The version command did not complete successfully.' };
129
+ } catch { return { ...row, status: 'failed', detail: 'Could not inspect the resolved executable.' }; }
130
+ };
131
+ // Fixed on every OS: python wins if it resolves, even when its probe fails.
132
+ let row = await one(command);
133
+ if (id === 'python') {
134
+ const other = await one('python3');
135
+ if (!row.path) row = other;
136
+ else if (other.path && other.path !== row.path) row.alternatives = [other];
137
+ }
138
+ if (id === 'dotnet' && row.path && row.status !== 'failed') {
139
+ try {
140
+ const executable = inspect(row.path, id, { platform, env });
141
+ if (!executable.error) {
142
+ const result = await run(executable.file, ['--list-runtimes'], { env: { ...probeEnv(env), DOTNET_CLI_HOME: cwd }, cwd });
143
+ if (result.code === 0) row.runtimes = result.output.split(/\r?\n/).map((line) => line.match(/^(Microsoft\.[\w.]+) (\d+\.\d+\.\d+(?:-[\w.-]+)?) \[/))
144
+ .filter(Boolean).map((match) => ({ name: match[1], version: match[2] }));
145
+ }
146
+ } catch { /* SDK result remains usable */ }
147
+ }
148
+ return row;
149
+ }
150
+
151
+ export function detectTools({ env, platform = process.platform, resolve = resolveCommand, exists = fs.existsSync } = {}) {
152
+ const tools = [];
153
+ const add = (id, label, file) => { if (file) tools.push({ id, label, path: file, status: 'detected' }); };
154
+ if (platform === 'win32') add('nvm-windows', 'NVM for Windows', resolve('nvm', env, platform));
155
+ else {
156
+ const home = value(env, 'HOME') || os.homedir();
157
+ const dir = value(env, 'NVM_DIR') || path.posix.join(home, '.nvm');
158
+ const file = path.posix.join(dir, 'nvm.sh');
159
+ if (path.posix.isAbsolute(file) && exists(file)) add('nvm', 'nvm', file);
160
+ }
161
+ for (const name of ['vfox', 'uv', 'pnpm']) add(name, name, resolve(name, env, platform));
162
+ return tools;
163
+ }
164
+
165
+ if (process.argv[2] === '--scan-environment' && process.send) {
166
+ // A fresh temporary directory contains no project files. Manager env is
167
+ // retained; no selected shell, project or session is inspected or changed.
168
+ let cwd;
169
+ try {
170
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-guild-environment-'));
171
+ process.chdir(cwd);
172
+ const env = { ...process.env };
173
+ process.send({ tools: detectTools({ env }) });
174
+ // Two batches bound concurrency while giving each runtime its own result.
175
+ for (let i = 0; i < RUNTIMES.length; i += 3) {
176
+ await Promise.all(RUNTIMES.slice(i, i + 3).map(async (definition) => {
177
+ const runtime = await scanRuntime(definition, { env, cwd });
178
+ process.send({ runtime });
179
+ }));
180
+ }
181
+ } 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
+ }
187
+ }
188
+ process.send({ done: true });
189
+ }
@@ -0,0 +1,86 @@
1
+ // Read-only snapshot of the manager environment. Discovery runs outside the
2
+ // manager: even a blocked filesystem lookup cannot hold up terminals or HTTP.
3
+ import { EventEmitter } from 'node:events';
4
+ import { fork } from 'node:child_process';
5
+ import { killWindowsTree } from './command-resolver.mjs';
6
+ import { RUNTIMES } from './environment-probe.mjs';
7
+
8
+ export const ENVIRONMENT_TIMEOUT_MS = 10000;
9
+
10
+ export class Environment extends EventEmitter {
11
+ constructor({ env, forkWorker = fork, timeoutMs = ENVIRONMENT_TIMEOUT_MS } = {}) {
12
+ super();
13
+ this.env = env ?? process.env;
14
+ this.forkWorker = forkWorker;
15
+ this.timeoutMs = timeoutMs;
16
+ this.closed = false;
17
+ this.run = null;
18
+ this.value = {
19
+ scope: 'manager', platform: process.platform, revision: 0, refreshing: false, checkedAt: null, error: null,
20
+ managerNode: { version: process.versions.node, path: process.execPath },
21
+ runtimes: RUNTIMES.map(({ id, label }) => ({ id, label, status: 'pending', version: null, path: null })),
22
+ tools: [],
23
+ };
24
+ }
25
+
26
+ snapshot() { return this.value; }
27
+
28
+ // Never awaits discovery. A refresh reads the manager's current environment;
29
+ // it does not reload shell profiles, the registry, or a client's working folder.
30
+ refresh() {
31
+ if (this.closed || this.run) return this.snapshot();
32
+ const run = { child: null, timer: null, rows: new Map(), tools: [] };
33
+ this.run = run;
34
+ this.value = { ...this.value, revision: this.value.revision + 1, refreshing: true, error: null };
35
+ const finish = (error = null) => {
36
+ if (this.run !== run) return;
37
+ this.run = null;
38
+ clearTimeout(run.timer);
39
+ this.stopWorker(run.child);
40
+ this.value = {
41
+ ...this.value, revision: this.value.revision + 1, refreshing: false, checkedAt: new Date().toISOString(), error,
42
+ runtimes: RUNTIMES.map(({ id, label }) => run.rows.get(id) ?? {
43
+ id, label, status: 'failed', version: null, path: null, detail: error || 'The environment check did not finish.',
44
+ }),
45
+ tools: run.tools,
46
+ };
47
+ if (!this.closed) this.emit('updated');
48
+ };
49
+ try {
50
+ // A preload in NODE_OPTIONS must not execute inside a passive scan.
51
+ const env = { ...this.env };
52
+ for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
53
+ run.child = this.forkWorker(new URL('./environment-probe.mjs', import.meta.url), ['--scan-environment'], {
54
+ env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
55
+ windowsHide: true, detached: process.platform !== 'win32',
56
+ });
57
+ run.child.on('message', (message) => {
58
+ if (this.run !== run) return;
59
+ if (message?.runtime && RUNTIMES.some((r) => r.id === message.runtime.id)) run.rows.set(message.runtime.id, message.runtime);
60
+ if (Array.isArray(message?.tools)) run.tools = message.tools;
61
+ if (message?.done) finish();
62
+ });
63
+ run.child.once('error', () => finish('Could not start the environment check.'));
64
+ run.child.once('exit', () => finish('The environment check stopped before finishing.'));
65
+ run.timer = setTimeout(() => finish('The environment check timed out.'), this.timeoutMs);
66
+ run.timer.unref();
67
+ } catch { finish('Could not start the environment check.'); }
68
+ this.emit('updated');
69
+ return this.snapshot();
70
+ }
71
+
72
+ stopWorker(child) {
73
+ if (!child?.pid) return;
74
+ if (process.platform === 'win32') killWindowsTree(child.pid);
75
+ else { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ } }
76
+ }
77
+
78
+ close() {
79
+ this.closed = true;
80
+ if (this.run) {
81
+ clearTimeout(this.run.timer);
82
+ this.stopWorker(this.run.child);
83
+ this.run = null;
84
+ }
85
+ }
86
+ }
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ProviderRegistry } from './providers.mjs';
8
+ import { Environment } from './environment.mjs';
8
9
  import { SessionManager } from './session-manager.mjs';
9
10
  import { UsageMonitor } from './usage.mjs';
10
11
  import { SessionHistory } from './session-history.mjs';
@@ -46,6 +47,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
46
47
  ensureDataDir();
47
48
  const token = loadOrCreateToken();
48
49
  const baseEnv = resolveBaseEnv();
50
+ const environment = new Environment({ env: baseEnv });
49
51
  const remoteAccess = new RemoteAccess({ file: paths.remoteAccess, env: baseEnv, loaded: remoteSettings, ...remoteOptions });
50
52
  const webDir = path.join(rootDir, 'web');
51
53
  // The hooks in examples/ call `agent-guild-report` by name; these shims
@@ -98,6 +100,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
98
100
  if (closing) return closing;
99
101
  console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
100
102
  clearInterval(versionTimer);
103
+ environment.close();
101
104
  remoteAccess.close();
102
105
  github.close();
103
106
  removeRuntimeFile();
@@ -124,6 +127,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
124
127
  api = createManagerServer({
125
128
  manager,
126
129
  registry,
130
+ environment,
127
131
  usage,
128
132
  history,
129
133
  modelStats,
@@ -104,6 +104,7 @@ export function createManagerServer({
104
104
  modelStats,
105
105
  news = null,
106
106
  changelog = null,
107
+ environment = null,
107
108
  github = null,
108
109
  token,
109
110
  host = '127.0.0.1',
@@ -307,6 +308,14 @@ export function createManagerServer({
307
308
  const session = await manager.upgrade();
308
309
  return sendJson(res, 201, { session: session.toJSON() });
309
310
  }
311
+ if (route === '/environment' && method === 'GET' && environment) {
312
+ if (!environment.snapshot().checkedAt) environment.refresh();
313
+ return sendJson(res, 200, environment.snapshot());
314
+ }
315
+ if (route === '/environment/refresh' && method === 'POST' && environment) {
316
+ await readJsonBody(req);
317
+ return sendJson(res, 202, environment.refresh());
318
+ }
310
319
  if (route === '/providers' && method === 'GET') {
311
320
  registry.refreshVersions().catch(() => {});
312
321
  return sendJson(res, 200, { providers: registry.list() });
@@ -544,6 +553,8 @@ export function createManagerServer({
544
553
  }
545
554
  manager.on('event', broadcast);
546
555
  registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
556
+ const environmentUpdated = () => broadcast({ type: 'environment.updated', environment: environment.snapshot() });
557
+ environment?.on('updated', environmentUpdated);
547
558
  selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
548
559
  news?.on('updated', () => broadcast({ type: 'news.updated' }));
549
560
  changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
@@ -650,6 +661,7 @@ export function createManagerServer({
650
661
  },
651
662
  /** @param {{ notice?: object }} [opts] a final event for the events clients */
652
663
  async close({ notice } = {}) {
664
+ environment?.off('updated', environmentUpdated);
653
665
  if (notice) await farewell(notice);
654
666
  clearInterval(heartbeat);
655
667
  for (const ws of wss.clients) ws.terminate();
package/web/app.js CHANGED
@@ -8,6 +8,7 @@ import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, cla
8
8
  import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
9
9
  import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
10
10
  import { createRemoteAccessUI } from './remote-access.js';
11
+ import { createEnvironmentUI } from './environment.js';
11
12
  import { matchFolders, readRecentFolders, rememberFolder } from './folders.js';
12
13
 
13
14
  const TOKEN_KEY = 'agentGuild.token';
@@ -1392,6 +1393,7 @@ function renderProviders() {
1392
1393
  renderVendorLinks(node, provider);
1393
1394
  renderAccounts(node, provider);
1394
1395
  renderShells(node, provider);
1396
+ state.environmentUI?.renderCard(node, provider);
1395
1397
  renderMultiplexers(node, provider);
1396
1398
  renderUsage(node, provider);
1397
1399
  renderReportingSetup(node, provider);
@@ -5379,6 +5381,7 @@ function connectEvents() {
5379
5381
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
5380
5382
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
5381
5383
  managerConnected(msg);
5384
+ state.environmentUI?.connected(msg.pid);
5382
5385
  renderSessions();
5383
5386
  if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
5384
5387
  restorePanes();
@@ -5391,6 +5394,8 @@ function connectEvents() {
5391
5394
  catchUpNotes(msg.notesRevision, msg.notesUnreadable === true);
5392
5395
  } else if (msg.type === 'notes.updated') {
5393
5396
  applyServerNotes(msg.notes);
5397
+ } else if (msg.type === 'environment.updated') {
5398
+ state.environmentUI?.updated(msg.environment);
5394
5399
  } else if (msg.type === 'remote-access.updated') {
5395
5400
  state.remoteAccessUI?.updated();
5396
5401
  } else if (msg.type === 'news.updated') {
@@ -5421,6 +5426,7 @@ function connectEvents() {
5421
5426
  };
5422
5427
  ws.onclose = (event) => {
5423
5428
  if (state.pageAway || state.eventsSocket !== ws) return;
5429
+ state.environmentUI?.disconnected();
5424
5430
  if (event?.code === 4403) {
5425
5431
  state.remoteRevoked = true;
5426
5432
  managerLoss.cancel();
@@ -5469,6 +5475,7 @@ let statsInterval;
5469
5475
  let newsTimer;
5470
5476
 
5471
5477
  function showAuth(message = '') {
5478
+ state.environmentUI?.close();
5472
5479
  state.remoteAccessUI?.setAvailable(null);
5473
5480
  managerLoss.cancel();
5474
5481
  closePanel();
@@ -5924,5 +5931,6 @@ publishTopbarHeight();
5924
5931
  applyDockLayout();
5925
5932
 
5926
5933
  state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
5934
+ state.environmentUI = createEnvironmentUI({ api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError });
5927
5935
  state.token = readTokenFromHash() || load(TOKEN_KEY);
5928
5936
  boot();
@@ -0,0 +1,125 @@
1
+ const STATES = { pending: 'Not checked', not_found: 'Not found', unavailable: 'Runtime unavailable', failed: 'Probe failed' };
2
+
3
+ export function runtimeValue(row) { return row.status === 'ok' ? row.version : STATES[row.status] || 'Probe failed'; }
4
+
5
+ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => false, document = globalThis.document }) {
6
+ const $ = (id) => document.getElementById(id);
7
+ const dialog = $('environment');
8
+ let data = null, request = 0, pending = false, online = false, managerPid = null, error = '', openerId = null;
9
+ const element = (tag, text, className) => {
10
+ const node = document.createElement(tag);
11
+ if (text !== undefined) node.textContent = text;
12
+ if (className) node.className = className;
13
+ return node;
14
+ };
15
+
16
+ function renderSummary(host) {
17
+ const values = host.querySelector('.environment-values');
18
+ const found = (data?.runtimes || []).filter((row) => row.status === 'ok');
19
+ values.replaceChildren(...found.flatMap((row) => [element('dt', row.label), element('dd', row.version)]));
20
+ const note = host.querySelector('.environment-note');
21
+ note.textContent = error ? 'Environment check unavailable'
22
+ : data?.refreshing ? data.checkedAt ? 'Refreshing · showing previous check' : 'Checking environment…'
23
+ : data?.error ? 'Some checks could not finish'
24
+ : !data ? 'Not checked' : !found.length ? 'No runtime versions verified' : 'Manager environment';
25
+ }
26
+
27
+ function rowElement(row) {
28
+ const node = element('section', undefined, 'environment-row');
29
+ const head = element('div', undefined, 'environment-row-head');
30
+ head.append(element('strong', row.label), element('span', runtimeValue(row)));
31
+ node.append(head);
32
+ if (row.command) node.append(element('p', `Command: ${row.command}`, 'environment-detail'));
33
+ if (row.path) node.append(element('code', row.path, 'environment-path'));
34
+ const detail = row.detail || (row.status === 'not_found' ? 'No executable was found on the manager’s PATH.' : '');
35
+ if (detail) node.append(element('p', detail, 'environment-detail'));
36
+ for (const alternate of row.alternatives || []) {
37
+ node.append(element('p', `Also found: ${alternate.command} · ${runtimeValue(alternate)}`, 'environment-detail'));
38
+ node.append(element('code', alternate.path, 'environment-path'));
39
+ if (alternate.detail) node.append(element('p', alternate.detail, 'environment-detail'));
40
+ }
41
+ for (const runtime of row.runtimes || []) node.append(element('p', `${runtime.name} ${runtime.version}`, 'environment-detail'));
42
+ return node;
43
+ }
44
+
45
+ function render() {
46
+ for (const host of document.querySelectorAll('.environment-summary:not([hidden])')) renderSummary(host);
47
+ $('environment-refresh').disabled = !online || pending || Boolean(data?.refreshing);
48
+ $('environment-refresh').textContent = pending || data?.refreshing ? 'Checking…' : 'Refresh';
49
+ $('environment-status').textContent = error || (data?.refreshing
50
+ ? data.checkedAt ? 'Refreshing. Previous results remain visible.' : 'Checking the manager environment…'
51
+ : data?.error || (data?.checkedAt ? `Last checked ${new Date(data.checkedAt).toLocaleString()}` : 'Not checked yet.'));
52
+ $('environment-runtimes').replaceChildren(...(data?.runtimes || []).map(rowElement));
53
+ const tools = data?.tools || [];
54
+ $('environment-tools').replaceChildren(...tools.map((tool) => {
55
+ const node = element('section', undefined, 'environment-row');
56
+ node.append(element('strong', `${tool.label} · Detected`), element('code', tool.path, 'environment-path'));
57
+ return node;
58
+ }));
59
+ $('environment-tools-empty').hidden = Boolean(tools.length);
60
+ $('environment-tools-empty').textContent = data?.refreshing ? 'Checking…' : data?.error ? 'Tool discovery did not finish.' : 'No supported tools detected.';
61
+ $('environment-manager-node').textContent = data?.managerNode
62
+ ? `Agent Guild is running on Node.js ${data.managerNode.version} (${data.managerNode.path}).` : '';
63
+ }
64
+
65
+ function updated(snapshot) {
66
+ if (!online || !snapshot || snapshot.scope !== 'manager') return;
67
+ if (data && snapshot.revision < data.revision) return;
68
+ data = snapshot;
69
+ error = '';
70
+ render();
71
+ }
72
+
73
+ async function load(refresh = false) {
74
+ if (!online || pending) return;
75
+ const current = ++request;
76
+ pending = true;
77
+ error = '';
78
+ render();
79
+ try {
80
+ const snapshot = await api(refresh ? 'POST' : 'GET', refresh ? '/environment/refresh' : '/environment', refresh ? {} : undefined);
81
+ if (current === request) updated(snapshot);
82
+ } catch (err) {
83
+ if (current !== request) return;
84
+ if (isAuthError(err)) onAuthError?.(err.message);
85
+ else error = 'Could not read the manager environment. Try Refresh.';
86
+ } finally {
87
+ if (current === request) { pending = false; render(); }
88
+ }
89
+ }
90
+
91
+ $('environment-close').addEventListener('click', () => dialog.close());
92
+ $('environment-refresh').addEventListener('click', () => load(true));
93
+ dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
94
+ dialog.addEventListener('close', () => {
95
+ if (openerId) document.querySelector(`.provider[data-id="${openerId}"] .environment-open`)?.focus();
96
+ openerId = null;
97
+ });
98
+ return {
99
+ updated,
100
+ connected(pid) {
101
+ if (pid !== managerPid) { data = null; managerPid = pid; }
102
+ request++; pending = false; online = true;
103
+ load();
104
+ },
105
+ disconnected() {
106
+ request++; pending = false; online = false;
107
+ error = 'Disconnected. Results are from the last check.';
108
+ render();
109
+ },
110
+ close() { openerId = null; dialog.close(); this.disconnected(); },
111
+ renderCard(card, provider) {
112
+ const host = card.querySelector('.environment-summary');
113
+ host.hidden = !Array.isArray(provider.shells);
114
+ if (host.hidden) return;
115
+ const button = host.querySelector('.environment-open');
116
+ button.dataset.muxFocus = `${provider.id}:environment`;
117
+ button.addEventListener('click', () => {
118
+ openerId = provider.id;
119
+ render();
120
+ if (!dialog.open) dialog.showModal();
121
+ });
122
+ renderSummary(host);
123
+ },
124
+ };
125
+ }
package/web/index.html CHANGED
@@ -344,6 +344,28 @@
344
344
  </div>
345
345
  </dialog>
346
346
 
347
+ <dialog id="environment" class="models environment-dialog" aria-labelledby="environment-title">
348
+ <div class="models-bar">
349
+ <div class="models-heading">
350
+ <h2 id="environment-title">Manager environment</h2>
351
+ <p class="sub">On the computer running Agent Guild</p>
352
+ </div>
353
+ <button id="environment-close" class="btn" type="button">Close</button>
354
+ </div>
355
+ <div class="models-body">
356
+ <p class="environment-detail">Versions available to the manager. Shell profiles, projects and existing sessions can use different versions.</p>
357
+ <p class="environment-detail">Python uses <code>python</code> when found, otherwise <code>python3</code>. A failed <code>python</code> check stays primary.</p>
358
+ <p id="environment-status" class="environment-detail" role="status"></p>
359
+ <button id="environment-refresh" class="btn" type="button">Refresh</button>
360
+ <div id="environment-runtimes"></div>
361
+ <h3>Detected tools</h3>
362
+ <p class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
363
+ <div id="environment-tools"></div>
364
+ <p id="environment-tools-empty" class="environment-detail"></p>
365
+ <p id="environment-manager-node" class="environment-detail"></p>
366
+ </div>
367
+ </dialog>
368
+
347
369
  <dialog id="models" class="models" aria-labelledby="models-title">
348
370
  <div class="models-bar">
349
371
  <span id="models-icon" class="provider-icon small"></span>
@@ -475,6 +497,11 @@
475
497
  </div>
476
498
  <div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
477
499
  <div class="shells" role="tablist" aria-label="Shells" hidden></div>
500
+ <div class="environment-summary" hidden>
501
+ <button class="environment-open" type="button" aria-haspopup="dialog">Environment details</button>
502
+ <dl class="environment-values"></dl>
503
+ <p class="environment-note"></p>
504
+ </div>
478
505
  <div class="usage"></div>
479
506
  <div class="model-stats"></div>
480
507
  <div class="reporting-row" hidden>
package/web/styles.css CHANGED
@@ -152,6 +152,20 @@ main { max-width: calc(1200px + var(--dock-space, 0px)); margin: 0 auto; padding
152
152
  .account-chip.unsigned { border-style: dashed; }
153
153
  .provider .usage { display: grid; gap: 4px; }
154
154
  .provider .usage:empty { display: none; }
155
+ .environment-summary { min-width: 0; font-size: .78rem; }
156
+ .environment-open { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; text-align: left; }
157
+ .environment-open:hover { text-decoration: underline; }
158
+ .environment-open:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: 2px; }
159
+ .environment-values { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 3px 10px; margin: 6px 0; }
160
+ .environment-values:empty { display: none; }
161
+ .environment-values dt { color: var(--muted); }
162
+ .environment-values dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
163
+ .environment-note { margin: 4px 0 0; color: var(--muted); font-size: .72rem; }
164
+ .environment-detail { font-size: .85rem; line-height: 1.5; color: var(--muted); overflow-wrap: anywhere; }
165
+ .environment-row { padding: 12px 0; border-bottom: 1px solid var(--border); }
166
+ .environment-row-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 14px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
167
+ .environment-row p { margin: 5px 0; }
168
+ .environment-path { display: block; margin-top: 5px; font-size: .8rem; overflow-wrap: anywhere; }
155
169
  .meter { display: grid; grid-template-columns: 5.5em 1fr auto; align-items: center; gap: 8px; font-size: 0.75rem; color: var(--muted); }
156
170
  .meter-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
157
171
  .meter-bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }