@oddessentials/agent-guild 0.29.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 +28 -2
- package/package.json +1 -1
- package/src/manager/config.mjs +2 -0
- package/src/manager/environment-probe.mjs +189 -0
- package/src/manager/environment.mjs +86 -0
- package/src/manager/main.mjs +6 -0
- package/src/manager/notes.mjs +120 -0
- package/src/manager/server.mjs +33 -4
- package/web/app.js +253 -19
- package/web/environment.js +125 -0
- package/web/index.html +28 -1
- package/web/styles.css +16 -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
|
|
@@ -160,8 +184,10 @@ vX.Y.Z** switches over when you are ready. The version badge opens
|
|
|
160
184
|
|
|
161
185
|
**Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
|
|
162
186
|
commands and to-dos in the side panel, next to your terminals. It saves up to
|
|
163
|
-
100,000 characters as you type
|
|
164
|
-
manager
|
|
187
|
+
100,000 characters as you type. The same notes follow every browser signed in
|
|
188
|
+
to this manager, including one opened through remote access. This browser
|
|
189
|
+
keeps a copy too: if the manager is unreachable, the line under the title
|
|
190
|
+
says the notes are saved in this browser only, and they sync when it is back.
|
|
165
191
|
|
|
166
192
|
**GitHub beside your work.** **GitHub** in the top bar opens the side panel.
|
|
167
193
|
Sign in to one or more GitHub accounts, then search the repositories of all
|
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": {
|
package/src/manager/config.mjs
CHANGED
|
@@ -45,6 +45,8 @@ export const paths = {
|
|
|
45
45
|
get accounts() { return path.join(dataDir(), 'accounts'); },
|
|
46
46
|
get github() { return path.join(dataDir(), 'github'); },
|
|
47
47
|
get remoteAccess() { return path.join(dataDir(), 'remote-access.json'); },
|
|
48
|
+
/** The shared notepad. A missing file means it was never stored. */
|
|
49
|
+
get notes() { return path.join(dataDir(), 'notes.json'); },
|
|
48
50
|
get log() { return path.join(dataDir(), 'manager.log'); },
|
|
49
51
|
/** Launchers for agent-guild-report, put first on every session's PATH. */
|
|
50
52
|
get shims() { return path.join(dataDir(), 'bin'); },
|
|
@@ -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';
|
|
@@ -13,6 +14,7 @@ import { NewsFeed } from './news.mjs';
|
|
|
13
14
|
import { Changelog } from './changelog.mjs';
|
|
14
15
|
import { GitHub } from './github.mjs';
|
|
15
16
|
import { createManagerServer } from './server.mjs';
|
|
17
|
+
import { createNotesStore } from './notes.mjs';
|
|
16
18
|
import { RemoteAccess, loadRemoteAccess } from './remote-access.mjs';
|
|
17
19
|
import { SelfUpdate } from './self-update.mjs';
|
|
18
20
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
@@ -45,6 +47,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
45
47
|
ensureDataDir();
|
|
46
48
|
const token = loadOrCreateToken();
|
|
47
49
|
const baseEnv = resolveBaseEnv();
|
|
50
|
+
const environment = new Environment({ env: baseEnv });
|
|
48
51
|
const remoteAccess = new RemoteAccess({ file: paths.remoteAccess, env: baseEnv, loaded: remoteSettings, ...remoteOptions });
|
|
49
52
|
const webDir = path.join(rootDir, 'web');
|
|
50
53
|
// The hooks in examples/ call `agent-guild-report` by name; these shims
|
|
@@ -97,6 +100,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
97
100
|
if (closing) return closing;
|
|
98
101
|
console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
|
|
99
102
|
clearInterval(versionTimer);
|
|
103
|
+
environment.close();
|
|
100
104
|
remoteAccess.close();
|
|
101
105
|
github.close();
|
|
102
106
|
removeRuntimeFile();
|
|
@@ -123,6 +127,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
123
127
|
api = createManagerServer({
|
|
124
128
|
manager,
|
|
125
129
|
registry,
|
|
130
|
+
environment,
|
|
126
131
|
usage,
|
|
127
132
|
history,
|
|
128
133
|
modelStats,
|
|
@@ -138,6 +143,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
138
143
|
extraHosts: remoteSettings.config.access.hosts,
|
|
139
144
|
extraOrigins: remoteSettings.config.access.origins,
|
|
140
145
|
remoteAccess,
|
|
146
|
+
notes: createNotesStore(paths.notes),
|
|
141
147
|
launcher: launcherPath(),
|
|
142
148
|
onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then(() => process.exit(0)),
|
|
143
149
|
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One notepad for every browser signed in to this manager.
|
|
2
|
+
// A missing file means notes were never stored. An empty string is a notepad
|
|
3
|
+
// the user cleared; those two must stay distinct so an old browser cannot
|
|
4
|
+
// put cleared notes back.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const NOTES_LIMIT = 100000;
|
|
11
|
+
/** Larger than the API's usual 64 KiB body, and enough for a full notepad after JSON escaping. */
|
|
12
|
+
export const NOTES_BODY_LIMIT = 1024 * 1024;
|
|
13
|
+
const NOTES_FILE_LIMIT = 2 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
function notesError(code, message, status, notes) {
|
|
16
|
+
// The store reports an unreadable file once, so the server does not log every request that hits it.
|
|
17
|
+
const error = Object.assign(new Error(message), { code, status, logged: code === 'notes_unreadable' });
|
|
18
|
+
if (notes) error.notes = notes;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {{ revision: string, text: string } | null} null when the file is not there yet */
|
|
23
|
+
function readNotes(file) {
|
|
24
|
+
let stat;
|
|
25
|
+
try { stat = fs.statSync(file); } catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') return null;
|
|
27
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
28
|
+
}
|
|
29
|
+
if (!stat.isFile() || stat.size > NOTES_FILE_LIMIT) throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
30
|
+
let value;
|
|
31
|
+
try { value = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
|
|
32
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
33
|
+
}
|
|
34
|
+
if (!value || value.version !== 1 || typeof value.revision !== 'string' || !value.revision || value.revision.length > 100
|
|
35
|
+
|| typeof value.text !== 'string' || value.text.length > NOTES_LIMIT) {
|
|
36
|
+
throw notesError('notes_unreadable', 'Notes could not be read.', 500);
|
|
37
|
+
}
|
|
38
|
+
return { revision: value.revision, text: value.text };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeNotes(file, notes) {
|
|
42
|
+
const contents = `${JSON.stringify({ version: 1, revision: notes.revision, text: notes.text }, null, 2)}\n`;
|
|
43
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
44
|
+
const next = `${file}.${randomUUID()}.tmp`;
|
|
45
|
+
let descriptor;
|
|
46
|
+
try {
|
|
47
|
+
descriptor = fs.openSync(next, 'wx', 0o600);
|
|
48
|
+
fs.writeFileSync(descriptor, contents);
|
|
49
|
+
fs.fsyncSync(descriptor);
|
|
50
|
+
fs.closeSync(descriptor);
|
|
51
|
+
descriptor = undefined;
|
|
52
|
+
fs.renameSync(next, file);
|
|
53
|
+
} finally {
|
|
54
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
55
|
+
try { fs.unlinkSync(next); } catch { /* renamed, or never created */ }
|
|
56
|
+
}
|
|
57
|
+
try { fs.chmodSync(file, 0o600); } catch { /* not supported on Windows */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} [file] omit for an in-memory store (tests). The real manager passes its notes file.
|
|
62
|
+
* @param {{ warn?: (message: string) => void }} [options] where the one line about an unreadable file goes
|
|
63
|
+
*/
|
|
64
|
+
export function createNotesStore(file, { warn = (message) => console.warn(message) } = {}) {
|
|
65
|
+
let current = { revision: null, text: '' };
|
|
66
|
+
let loaded = !file;
|
|
67
|
+
let warned = false;
|
|
68
|
+
|
|
69
|
+
/** An unreadable file is read again next time, so fixing or removing it needs no restart. */
|
|
70
|
+
function ensure() {
|
|
71
|
+
if (loaded) return;
|
|
72
|
+
let read;
|
|
73
|
+
try { read = readNotes(file); } catch (error) {
|
|
74
|
+
if (!warned) warn(`[notes] ${file} cannot be read, so notes are not shared until it is fixed or removed.`);
|
|
75
|
+
warned = true;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
warned = false;
|
|
79
|
+
current = read ?? { revision: null, text: '' };
|
|
80
|
+
loaded = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function snapshot() {
|
|
84
|
+
ensure();
|
|
85
|
+
return { revision: current.revision, text: current.text };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function save(body) {
|
|
89
|
+
ensure();
|
|
90
|
+
if (!body || typeof body.text !== 'string') throw notesError('bad_notes', 'Notes must be text.', 400);
|
|
91
|
+
if (body.text.length > NOTES_LIMIT) {
|
|
92
|
+
throw notesError('notes_too_long', `Notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters.`, 400);
|
|
93
|
+
}
|
|
94
|
+
const revision = body.revision ?? null;
|
|
95
|
+
if (revision !== null && (typeof revision !== 'string' || !revision || revision.length > 100)) {
|
|
96
|
+
throw notesError('bad_notes', 'Notes must be text.', 400);
|
|
97
|
+
}
|
|
98
|
+
if (revision !== current.revision) {
|
|
99
|
+
throw notesError('stale_notes', 'Notes changed in another browser.', 409, { revision: current.revision, text: current.text });
|
|
100
|
+
}
|
|
101
|
+
const next = { revision: randomUUID(), text: body.text };
|
|
102
|
+
if (file) writeNotes(file, next);
|
|
103
|
+
current = next;
|
|
104
|
+
loaded = true;
|
|
105
|
+
return { revision: next.revision, text: next.text };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
snapshot,
|
|
110
|
+
save,
|
|
111
|
+
/** `{ known: false }` when the file cannot be read, so a client does not treat that as an empty notepad. */
|
|
112
|
+
helloRevision() {
|
|
113
|
+
try {
|
|
114
|
+
return { known: true, revision: snapshot().revision };
|
|
115
|
+
} catch {
|
|
116
|
+
return { known: false };
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { createViews } from './github-views.mjs';
|
|
|
12
12
|
import { createFolderOpener } from './folder-opener.mjs';
|
|
13
13
|
import { createFolderBrowser } from './folder-browser.mjs';
|
|
14
14
|
import { normalizeAccess } from './access-policy.mjs';
|
|
15
|
+
import { NOTES_BODY_LIMIT, createNotesStore } from './notes.mjs';
|
|
15
16
|
|
|
16
17
|
const require = createRequire(import.meta.url);
|
|
17
18
|
const API = '/api/v1';
|
|
@@ -63,7 +64,7 @@ class HttpError extends Error {
|
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function readJsonBody(req) {
|
|
67
|
+
function readJsonBody(req, max = MAX_BODY) {
|
|
67
68
|
return new Promise((resolve, reject) => {
|
|
68
69
|
let size = 0;
|
|
69
70
|
const chunks = [];
|
|
@@ -71,7 +72,7 @@ function readJsonBody(req) {
|
|
|
71
72
|
req.on('data', (chunk) => {
|
|
72
73
|
if (tooLarge) return; // drain the rest so the 413 response can be read
|
|
73
74
|
size += chunk.length;
|
|
74
|
-
if (size >
|
|
75
|
+
if (size > max) {
|
|
75
76
|
tooLarge = true;
|
|
76
77
|
chunks.length = 0;
|
|
77
78
|
reject(new HttpError(413, 'request body too large', 'too_large'));
|
|
@@ -103,6 +104,7 @@ export function createManagerServer({
|
|
|
103
104
|
modelStats,
|
|
104
105
|
news = null,
|
|
105
106
|
changelog = null,
|
|
107
|
+
environment = null,
|
|
106
108
|
github = null,
|
|
107
109
|
token,
|
|
108
110
|
host = '127.0.0.1',
|
|
@@ -113,6 +115,8 @@ export function createManagerServer({
|
|
|
113
115
|
extraHosts = [],
|
|
114
116
|
extraOrigins = [],
|
|
115
117
|
remoteAccess = null,
|
|
118
|
+
/** In-memory when omitted, so a test server never reads the user's notes file. */
|
|
119
|
+
notes = createNotesStore(),
|
|
116
120
|
folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
117
121
|
folderBrowser = createFolderBrowser(),
|
|
118
122
|
/** The double-click launcher file for this platform, or null when the package carries none. */
|
|
@@ -252,6 +256,15 @@ export function createManagerServer({
|
|
|
252
256
|
|
|
253
257
|
requireAuth(req, url);
|
|
254
258
|
|
|
259
|
+
if (route === '/notes' && method === 'GET') {
|
|
260
|
+
return sendJson(res, 200, { notes: notes.snapshot() });
|
|
261
|
+
}
|
|
262
|
+
if (route === '/notes' && method === 'PUT') {
|
|
263
|
+
const saved = notes.save(await readJsonBody(req, NOTES_BODY_LIMIT));
|
|
264
|
+
broadcast({ type: 'notes.updated', notes: saved });
|
|
265
|
+
return sendJson(res, 200, { notes: saved });
|
|
266
|
+
}
|
|
267
|
+
|
|
255
268
|
if (remoteAccess && route === '/remote-access' && method === 'GET') {
|
|
256
269
|
return sendJson(res, 200, { remoteAccess: remoteAccess.snapshot() });
|
|
257
270
|
}
|
|
@@ -295,6 +308,14 @@ export function createManagerServer({
|
|
|
295
308
|
const session = await manager.upgrade();
|
|
296
309
|
return sendJson(res, 201, { session: session.toJSON() });
|
|
297
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
|
+
}
|
|
298
319
|
if (route === '/providers' && method === 'GET') {
|
|
299
320
|
registry.refreshVersions().catch(() => {});
|
|
300
321
|
return sendJson(res, 200, { providers: registry.list() });
|
|
@@ -486,12 +507,13 @@ export function createManagerServer({
|
|
|
486
507
|
else serveStatic(req, res, url.pathname);
|
|
487
508
|
} catch (err) {
|
|
488
509
|
const status = err.status || 500;
|
|
489
|
-
if (status >= 500) console.error('[server]', err);
|
|
510
|
+
if (status >= 500 && !err.logged) console.error('[server]', err);
|
|
490
511
|
if (!res.headersSent) {
|
|
491
512
|
const error = { code: err.code || 'error', message: err.message };
|
|
492
513
|
if (err.running !== undefined) error.running = err.running;
|
|
493
514
|
if (err.pending !== undefined) error.pending = err.pending;
|
|
494
515
|
if (err.target !== undefined) error.target = err.target;
|
|
516
|
+
if (err.notes !== undefined) error.notes = err.notes;
|
|
495
517
|
sendJson(res, status, { error });
|
|
496
518
|
}
|
|
497
519
|
}
|
|
@@ -531,6 +553,8 @@ export function createManagerServer({
|
|
|
531
553
|
}
|
|
532
554
|
manager.on('event', broadcast);
|
|
533
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);
|
|
534
558
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
535
559
|
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
536
560
|
changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
|
|
@@ -539,7 +563,11 @@ export function createManagerServer({
|
|
|
539
563
|
|
|
540
564
|
function handleEvents(ws, req) {
|
|
541
565
|
eventClients.add(ws);
|
|
542
|
-
|
|
566
|
+
const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), sessions: manager.list() };
|
|
567
|
+
const notesHello = notes.helloRevision();
|
|
568
|
+
if (notesHello.known) hello.notesRevision = notesHello.revision;
|
|
569
|
+
else hello.notesUnreadable = true;
|
|
570
|
+
safeSend(ws, hello);
|
|
543
571
|
ws.on('close', () => eventClients.delete(ws));
|
|
544
572
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
545
573
|
}
|
|
@@ -633,6 +661,7 @@ export function createManagerServer({
|
|
|
633
661
|
},
|
|
634
662
|
/** @param {{ notice?: object }} [opts] a final event for the events clients */
|
|
635
663
|
async close({ notice } = {}) {
|
|
664
|
+
environment?.off('updated', environmentUpdated);
|
|
636
665
|
if (notice) await farewell(notice);
|
|
637
666
|
clearInterval(heartbeat);
|
|
638
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';
|
|
@@ -31,6 +32,7 @@ const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
|
|
|
31
32
|
const SOUND_KEY = 'agentGuild.sound';
|
|
32
33
|
const VOICE_KEY = 'agentGuild.voice';
|
|
33
34
|
const NOTES_KEY = 'agentGuild.notes';
|
|
35
|
+
const NOTES_REV_KEY = 'agentGuild.notesRevision';
|
|
34
36
|
const DOCK_KEY = 'agentGuild.dock';
|
|
35
37
|
const DOCK_WIDTH_KEY = 'agentGuild.dockWidth';
|
|
36
38
|
const PANES_KEY = 'agentGuild.panes';
|
|
@@ -366,35 +368,63 @@ function moveInMenu(e) {
|
|
|
366
368
|
/** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
|
|
367
369
|
const NOTES_LIMIT = 100000;
|
|
368
370
|
const NOTES_STATUS = {
|
|
369
|
-
saved: 'Saved
|
|
371
|
+
saved: 'Saved and synced across your browsers',
|
|
372
|
+
local: 'Saved in this browser only. Syncs when the manager is back.',
|
|
373
|
+
damaged: 'Not syncing: the manager’s notes file is damaged. Saved in this browser only.',
|
|
370
374
|
long: `Not saved: notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters. Shorten them to save.`,
|
|
371
375
|
refused: 'Not saved: the browser’s storage for this page is full or turned off. Copy what you need before you close the page.',
|
|
372
376
|
};
|
|
377
|
+
/** How long a burst of typing waits before one save to the manager. */
|
|
378
|
+
const NOTES_PUSH_MS = 400;
|
|
379
|
+
/** Browsers drop a keepalive request larger than 64 KiB, so a bigger notepad waits for the next open. */
|
|
380
|
+
const NOTES_KEEPALIVE_BYTES = 60 * 1024;
|
|
373
381
|
/**
|
|
374
382
|
* `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
|
|
375
|
-
* panel is saved, too `long` or `refused`; `
|
|
383
|
+
* panel is saved, too `long` or `refused`; `sync`: whether the manager has it (`ok`), could not be reached
|
|
384
|
+
* (`local`) or cannot read its notes file (`damaged`); `shown`: the status the line under the title shows.
|
|
385
|
+
* `base`: the manager revision this page last saved from. `acked`: the text of that revision.
|
|
386
|
+
* `flight`: the save to the manager under way. Text typed meanwhile is sent when it finishes.
|
|
376
387
|
*/
|
|
377
|
-
const notesView = { saved: '', status: 'saved', shown: 'saved' };
|
|
388
|
+
const notesView = { saved: '', status: 'saved', sync: 'ok', shown: 'saved', base: null, acked: null, timer: 0, flight: null };
|
|
378
389
|
|
|
379
390
|
/** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
|
|
380
391
|
function refreshNotes() {
|
|
381
392
|
let text;
|
|
382
393
|
// Unlike load(), a read that fails is not taken for empty notes.
|
|
383
394
|
try { text = localStorage.getItem(NOTES_KEY) ?? ''; } catch { return; }
|
|
384
|
-
|
|
385
|
-
notesView.saved
|
|
386
|
-
notesView.
|
|
387
|
-
|
|
395
|
+
const revision = load(NOTES_REV_KEY);
|
|
396
|
+
if (text === notesView.saved && revision === notesView.base) return;
|
|
397
|
+
if (text !== notesView.saved) {
|
|
398
|
+
notesView.saved = text;
|
|
399
|
+
notesView.status = 'saved';
|
|
400
|
+
$('notes-text').value = text;
|
|
401
|
+
}
|
|
402
|
+
// The revision is kept only while the stored text is the one it names, so that text is on the manager.
|
|
403
|
+
if (revision) notesView.acked = text;
|
|
404
|
+
if (revision !== notesView.base) notesView.base = revision;
|
|
388
405
|
renderNotesStatus();
|
|
389
406
|
guardLeaving();
|
|
390
407
|
}
|
|
391
408
|
|
|
392
|
-
/** Saves the notes on every change; emptying them removes the saved copy. */
|
|
409
|
+
/** Saves the notes on every change; emptying them removes the saved copy. This stays in the browser. */
|
|
393
410
|
function saveNotes() {
|
|
394
411
|
const text = $('notes-text').value;
|
|
395
|
-
if (text.length > NOTES_LIMIT)
|
|
396
|
-
|
|
397
|
-
|
|
412
|
+
if (text.length > NOTES_LIMIT) {
|
|
413
|
+
notesView.status = 'long';
|
|
414
|
+
renderNotesStatus();
|
|
415
|
+
guardLeaving();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
// A reload can tell these keystrokes have not reached the manager: the stored revision goes first, so
|
|
419
|
+
// stored text never sits beside a revision it does not match. The revision in memory stays, for the save.
|
|
420
|
+
// An empty panel is probed with the same text, and that must not look like a new edit.
|
|
421
|
+
const revision = text !== notesView.saved ? load(NOTES_REV_KEY) : null;
|
|
422
|
+
if (revision) save(NOTES_REV_KEY, null);
|
|
423
|
+
if (!save(NOTES_KEY, text || null)) {
|
|
424
|
+
// The stored text did not change, so it still matches its revision.
|
|
425
|
+
if (revision) save(NOTES_REV_KEY, revision);
|
|
426
|
+
notesView.status = 'refused';
|
|
427
|
+
} else {
|
|
398
428
|
notesView.status = 'saved';
|
|
399
429
|
notesView.saved = text;
|
|
400
430
|
}
|
|
@@ -404,16 +434,30 @@ function saveNotes() {
|
|
|
404
434
|
|
|
405
435
|
/** The line under the title is a live region, so it is rewritten only when the status changes. */
|
|
406
436
|
function renderNotesStatus() {
|
|
407
|
-
|
|
408
|
-
|
|
437
|
+
// A problem saving in this browser outranks one reaching the manager.
|
|
438
|
+
const shown = notesView.status === 'saved' && notesView.sync !== 'ok' ? notesView.sync : notesView.status;
|
|
439
|
+
if (notesView.shown === shown) return;
|
|
440
|
+
notesView.shown = shown;
|
|
409
441
|
const sub = $('notes-sub');
|
|
410
|
-
sub.textContent = NOTES_STATUS[
|
|
411
|
-
sub.classList.toggle('warn',
|
|
442
|
+
sub.textContent = NOTES_STATUS[shown];
|
|
443
|
+
sub.classList.toggle('warn', shown !== 'saved');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Whether the manager has the notes. Typing never changes this, so the line does not flicker. */
|
|
447
|
+
function setNotesSync(sync) {
|
|
448
|
+
if (notesView.sync === sync) return;
|
|
449
|
+
notesView.sync = sync;
|
|
450
|
+
renderNotesStatus();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A save or read that failed. A file the manager cannot read is reported as such; anything else is unreachable. */
|
|
454
|
+
function notesFailed(error) {
|
|
455
|
+
setNotesSync(error?.code === 'notes_unreadable' ? 'damaged' : 'local');
|
|
412
456
|
}
|
|
413
457
|
|
|
414
458
|
/** Another tab saved the notes, or cleared this page's storage. */
|
|
415
459
|
function notesStored(e) {
|
|
416
|
-
if (e.key === NOTES_KEY || e.key === null) refreshNotes();
|
|
460
|
+
if (e.key === NOTES_KEY || e.key === NOTES_REV_KEY || e.key === null) refreshNotes();
|
|
417
461
|
}
|
|
418
462
|
|
|
419
463
|
function openNotes({ focus = true } = {}) {
|
|
@@ -430,6 +474,181 @@ function toggleNotes() {
|
|
|
430
474
|
else openNotes();
|
|
431
475
|
}
|
|
432
476
|
|
|
477
|
+
function sharedPrefix(left, right) {
|
|
478
|
+
const end = Math.min(left.length, right.length);
|
|
479
|
+
let i = 0;
|
|
480
|
+
while (i < end && left.charCodeAt(i) === right.charCodeAt(i)) i += 1;
|
|
481
|
+
return i;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Takes the manager's notes. A selection that still sits in the unchanged prefix stays put. */
|
|
485
|
+
function adoptNotes(notes) {
|
|
486
|
+
const area = $('notes-text');
|
|
487
|
+
const next = notes.text;
|
|
488
|
+
if (typeof next !== 'string') return;
|
|
489
|
+
if (next !== area.value) {
|
|
490
|
+
const start = area.selectionStart;
|
|
491
|
+
const end = area.selectionEnd;
|
|
492
|
+
const previous = area.value;
|
|
493
|
+
const focused = document.activeElement === area;
|
|
494
|
+
area.value = next;
|
|
495
|
+
if (focused) {
|
|
496
|
+
const keep = sharedPrefix(previous, next);
|
|
497
|
+
if (start <= keep && end <= keep) area.setSelectionRange(start, end);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
notesView.saved = next;
|
|
501
|
+
notesView.acked = next;
|
|
502
|
+
notesView.base = notes.revision ?? null;
|
|
503
|
+
notesView.status = 'saved';
|
|
504
|
+
notesView.sync = 'ok';
|
|
505
|
+
// Revision last, and only over text that was stored: a stored revision always names the stored text.
|
|
506
|
+
save(NOTES_REV_KEY, null);
|
|
507
|
+
if (save(NOTES_KEY, next || null)) save(NOTES_REV_KEY, notes.revision || null);
|
|
508
|
+
renderNotesStatus();
|
|
509
|
+
guardLeaving();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function notesPushable() {
|
|
513
|
+
const text = $('notes-text').value;
|
|
514
|
+
if (notesView.status !== 'saved' || text.length > NOTES_LIMIT) return false;
|
|
515
|
+
if (text === notesView.acked) return false;
|
|
516
|
+
if (notesView.base == null && text === '') return false;
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function scheduleNotesPush(delay = NOTES_PUSH_MS) {
|
|
521
|
+
clearTimeout(notesView.timer);
|
|
522
|
+
notesView.timer = 0;
|
|
523
|
+
if (!notesPushable()) return Promise.resolve();
|
|
524
|
+
if (delay === 0) return pushNotes();
|
|
525
|
+
notesView.timer = setTimeout(() => { notesView.timer = 0; void pushNotes(); }, delay);
|
|
526
|
+
return Promise.resolve();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The page is being hidden or closed: send what the debounce still holds. */
|
|
530
|
+
function flushNotes() {
|
|
531
|
+
void scheduleNotesPush(0);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* One save to the manager at a time, so two of this page's own saves can never race each other. Text typed
|
|
536
|
+
* while a save is under way follows it. A conflict sends this page's text once more at the manager's revision:
|
|
537
|
+
* the latest typing wins, and the text on screen is never replaced by an older copy.
|
|
538
|
+
*/
|
|
539
|
+
function pushNotes() {
|
|
540
|
+
// The save under way checks for newer text when it finishes.
|
|
541
|
+
if (notesView.flight) return notesView.flight;
|
|
542
|
+
const run = (async () => {
|
|
543
|
+
let resent = false;
|
|
544
|
+
for (;;) {
|
|
545
|
+
if (!notesPushable()) return;
|
|
546
|
+
const outcome = await sendNotes($('notes-text').value);
|
|
547
|
+
if (outcome === 'stale' && !resent) resent = true;
|
|
548
|
+
else if (outcome !== 'ok') return;
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
notesView.flight = run;
|
|
552
|
+
return run.finally(() => { notesView.flight = null; });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** @returns {Promise<'ok'|'stale'|'failed'>} `stale`: the manager moved on, and `base` now names its revision. */
|
|
556
|
+
async function sendNotes(text) {
|
|
557
|
+
const body = { revision: notesView.base, text };
|
|
558
|
+
// A hidden page may be closing: keepalive lets the save outlive it, for bodies the browser allows.
|
|
559
|
+
const keepalive = document.visibilityState === 'hidden'
|
|
560
|
+
&& new TextEncoder().encode(JSON.stringify(body)).length <= NOTES_KEEPALIVE_BYTES;
|
|
561
|
+
let payload;
|
|
562
|
+
try {
|
|
563
|
+
payload = (await api('PUT', '/notes', body, { keepalive })).notes;
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof AuthError) {
|
|
566
|
+
showAuth(error.message);
|
|
567
|
+
return 'failed';
|
|
568
|
+
}
|
|
569
|
+
if (error.code !== 'stale_notes' || !error.notes || typeof error.notes.text !== 'string') {
|
|
570
|
+
notesFailed(error);
|
|
571
|
+
return 'failed';
|
|
572
|
+
}
|
|
573
|
+
// The manager already holds exactly this text, saved by another tab or an earlier request.
|
|
574
|
+
if (error.notes.text === $('notes-text').value) {
|
|
575
|
+
adoptNotes(error.notes);
|
|
576
|
+
return 'ok';
|
|
577
|
+
}
|
|
578
|
+
notesView.base = error.notes.revision ?? null;
|
|
579
|
+
return 'stale';
|
|
580
|
+
}
|
|
581
|
+
if (!payload || typeof payload.text !== 'string') {
|
|
582
|
+
notesFailed();
|
|
583
|
+
return 'failed';
|
|
584
|
+
}
|
|
585
|
+
if ($('notes-text').value === text) adoptNotes(payload);
|
|
586
|
+
else {
|
|
587
|
+
// Typing went on during the save: the newer text follows from this revision.
|
|
588
|
+
notesView.base = payload.revision ?? notesView.base;
|
|
589
|
+
setNotesSync('ok');
|
|
590
|
+
}
|
|
591
|
+
return 'ok';
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Another browser's save. Our own echo matches the text already on screen and only adopts the revision. */
|
|
595
|
+
function applyServerNotes(notes) {
|
|
596
|
+
if (!notes || typeof notes.text !== 'string') return;
|
|
597
|
+
const area = $('notes-text');
|
|
598
|
+
if (notes.text === area.value) {
|
|
599
|
+
adoptNotes(notes);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (notesView.acked !== null && area.value !== notesView.acked) {
|
|
603
|
+
notesView.base = notes.revision ?? notesView.base;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
adoptNotes(notes);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The notes part of hello. A missing revision means this manager cannot share notes. */
|
|
610
|
+
async function catchUpNotes(revision, unreadable) {
|
|
611
|
+
if (unreadable) {
|
|
612
|
+
setNotesSync('damaged');
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (revision === undefined) return;
|
|
616
|
+
// Hello can arrive before the first read. Load this browser's copy before deciding, unless typing has started.
|
|
617
|
+
if (notesView.saved === '' && $('notes-text').value === '' && notesView.acked === null) refreshNotes();
|
|
618
|
+
const local = $('notes-text').value;
|
|
619
|
+
// No file yet, or it was removed. Text this browser has is the first copy. An empty panel writes nothing.
|
|
620
|
+
if (revision === null) notesView.acked = local ? null : '';
|
|
621
|
+
if (revision === null || revision === notesView.base) {
|
|
622
|
+
if (notesPushable()) await scheduleNotesPush(0);
|
|
623
|
+
else setNotesSync('ok');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
await pullNotes();
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The manager moved on while this page was away. Its copy wins unless this browser has edits it never sent. */
|
|
630
|
+
async function pullNotes() {
|
|
631
|
+
let notes;
|
|
632
|
+
try {
|
|
633
|
+
notes = (await api('GET', '/notes')).notes;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (error instanceof AuthError) showAuth(error.message);
|
|
636
|
+
else notesFailed(error);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (!notes || typeof notes.text !== 'string') return;
|
|
640
|
+
const local = $('notes-text').value;
|
|
641
|
+
// `acked` is unknown only when this browser holds text no revision names: an edit that never reached the manager.
|
|
642
|
+
const unsent = local !== notes.text && (notesView.acked === null ? local !== '' : local !== notesView.acked);
|
|
643
|
+
if (!unsent) {
|
|
644
|
+
adoptNotes(notes);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
notesView.base = notes.revision ?? null;
|
|
648
|
+
setNotesSync('ok');
|
|
649
|
+
await scheduleNotesPush(0);
|
|
650
|
+
}
|
|
651
|
+
|
|
433
652
|
// ---- dock -----------------------------------------------------------------
|
|
434
653
|
|
|
435
654
|
const DOCK_PANELS = ['github', 'notes'];
|
|
@@ -717,9 +936,10 @@ function renderAgents(container, agents, shells = []) {
|
|
|
717
936
|
|
|
718
937
|
class AuthError extends Error {}
|
|
719
938
|
|
|
720
|
-
async function api(method, path, body) {
|
|
939
|
+
async function api(method, path, body, options = {}) {
|
|
721
940
|
const res = await fetch(`/api/v1${path}`, {
|
|
722
941
|
method,
|
|
942
|
+
keepalive: options.keepalive === true,
|
|
723
943
|
headers: {
|
|
724
944
|
Authorization: `Bearer ${state.token}`,
|
|
725
945
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
@@ -1173,6 +1393,7 @@ function renderProviders() {
|
|
|
1173
1393
|
renderVendorLinks(node, provider);
|
|
1174
1394
|
renderAccounts(node, provider);
|
|
1175
1395
|
renderShells(node, provider);
|
|
1396
|
+
state.environmentUI?.renderCard(node, provider);
|
|
1176
1397
|
renderMultiplexers(node, provider);
|
|
1177
1398
|
renderUsage(node, provider);
|
|
1178
1399
|
renderReportingSetup(node, provider);
|
|
@@ -5160,6 +5381,7 @@ function connectEvents() {
|
|
|
5160
5381
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
5161
5382
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
5162
5383
|
managerConnected(msg);
|
|
5384
|
+
state.environmentUI?.connected(msg.pid);
|
|
5163
5385
|
renderSessions();
|
|
5164
5386
|
if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
|
|
5165
5387
|
restorePanes();
|
|
@@ -5169,6 +5391,11 @@ function connectEvents() {
|
|
|
5169
5391
|
// A changelog.updated sent while the socket was down is lost; catch up the open panel.
|
|
5170
5392
|
if ($('changelog').open) loadChangelog();
|
|
5171
5393
|
if (dockShows('github')) loadGitHub();
|
|
5394
|
+
catchUpNotes(msg.notesRevision, msg.notesUnreadable === true);
|
|
5395
|
+
} else if (msg.type === 'notes.updated') {
|
|
5396
|
+
applyServerNotes(msg.notes);
|
|
5397
|
+
} else if (msg.type === 'environment.updated') {
|
|
5398
|
+
state.environmentUI?.updated(msg.environment);
|
|
5172
5399
|
} else if (msg.type === 'remote-access.updated') {
|
|
5173
5400
|
state.remoteAccessUI?.updated();
|
|
5174
5401
|
} else if (msg.type === 'news.updated') {
|
|
@@ -5199,6 +5426,7 @@ function connectEvents() {
|
|
|
5199
5426
|
};
|
|
5200
5427
|
ws.onclose = (event) => {
|
|
5201
5428
|
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
5429
|
+
state.environmentUI?.disconnected();
|
|
5202
5430
|
if (event?.code === 4403) {
|
|
5203
5431
|
state.remoteRevoked = true;
|
|
5204
5432
|
managerLoss.cancel();
|
|
@@ -5247,6 +5475,7 @@ let statsInterval;
|
|
|
5247
5475
|
let newsTimer;
|
|
5248
5476
|
|
|
5249
5477
|
function showAuth(message = '') {
|
|
5478
|
+
state.environmentUI?.close();
|
|
5250
5479
|
state.remoteAccessUI?.setAvailable(null);
|
|
5251
5480
|
managerLoss.cancel();
|
|
5252
5481
|
closePanel();
|
|
@@ -5457,7 +5686,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
5457
5686
|
addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
|
|
5458
5687
|
addEventListener('resize', () => { if (tipFor) hideTip(); });
|
|
5459
5688
|
$('notes-open').addEventListener('click', firstClick(toggleNotes));
|
|
5460
|
-
$('notes-text').addEventListener('input', saveNotes);
|
|
5689
|
+
$('notes-text').addEventListener('input', () => { saveNotes(); void scheduleNotesPush(); });
|
|
5461
5690
|
addEventListener('storage', notesStored);
|
|
5462
5691
|
// On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
|
|
5463
5692
|
addEventListener('pageshow', refreshNotes);
|
|
@@ -5474,12 +5703,16 @@ $('news').addEventListener('close', () => {
|
|
|
5474
5703
|
newsView.opener = null;
|
|
5475
5704
|
});
|
|
5476
5705
|
document.addEventListener('visibilitychange', () => {
|
|
5477
|
-
if (document.visibilityState !== 'visible')
|
|
5706
|
+
if (document.visibilityState !== 'visible') {
|
|
5707
|
+
stopDictation();
|
|
5708
|
+
flushNotes();
|
|
5709
|
+
}
|
|
5478
5710
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
5479
5711
|
if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
|
|
5480
5712
|
else scheduleRuns();
|
|
5481
5713
|
});
|
|
5482
5714
|
addEventListener('pagehide', () => {
|
|
5715
|
+
flushNotes();
|
|
5483
5716
|
state.pageAway = true;
|
|
5484
5717
|
activityFavicon.setPaused(true);
|
|
5485
5718
|
terminalCopy.close();
|
|
@@ -5698,5 +5931,6 @@ publishTopbarHeight();
|
|
|
5698
5931
|
applyDockLayout();
|
|
5699
5932
|
|
|
5700
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 });
|
|
5701
5935
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
5702
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
|
@@ -258,7 +258,7 @@
|
|
|
258
258
|
</div>
|
|
259
259
|
</section>
|
|
260
260
|
<section id="notes" class="dock-panel notes-panel" role="tabpanel" aria-labelledby="notes-title" hidden>
|
|
261
|
-
<p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved
|
|
261
|
+
<p id="notes-sub" class="dock-sub sub" aria-live="polite">Saved and synced across your browsers</p>
|
|
262
262
|
<div class="dock-body">
|
|
263
263
|
<textarea id="notes-text" class="notes-text" aria-labelledby="notes-title" aria-describedby="notes-sub" autocomplete="off" placeholder="Prompts, commands, to-dos: anything you want to keep at hand"></textarea>
|
|
264
264
|
</div>
|
|
@@ -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; }
|
|
@@ -743,6 +757,8 @@ a.news-link:visited { color: var(--muted); }
|
|
|
743
757
|
}
|
|
744
758
|
.notes-text:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
745
759
|
.notes-panel .sub.warn { color: var(--warn); }
|
|
760
|
+
/* Room below the status line for the field's focus ring, and for a warning that wraps to two lines. */
|
|
761
|
+
.notes-panel .dock-sub { flex-shrink: 0; padding-bottom: 8px; line-height: 1.4; }
|
|
746
762
|
@media (pointer: coarse) {
|
|
747
763
|
/* iOS zooms in on a field with smaller text when it takes focus. */
|
|
748
764
|
.notes-text { font-size: 16px; }
|