@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 +24 -0
- package/package.json +1 -1
- package/src/manager/environment-probe.mjs +189 -0
- package/src/manager/environment.mjs +86 -0
- package/src/manager/main.mjs +4 -0
- package/src/manager/server.mjs +12 -0
- package/web/app.js +8 -0
- package/web/environment.js +125 -0
- package/web/index.html +27 -0
- package/web/styles.css +14 -0
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.
|
|
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
|
+
}
|
package/src/manager/main.mjs
CHANGED
|
@@ -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,
|
package/src/manager/server.mjs
CHANGED
|
@@ -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; }
|