@oddessentials/agent-guild 0.30.0 → 0.32.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 +29 -2
- package/package.json +1 -1
- package/src/manager/environment-probe.mjs +189 -0
- package/src/manager/environment.mjs +86 -0
- package/src/manager/github-views.mjs +37 -1
- package/src/manager/main.mjs +4 -0
- package/src/manager/server.mjs +14 -1
- package/web/app.js +203 -4
- package/web/environment.js +127 -0
- package/web/index.html +49 -0
- package/web/styles.css +32 -1
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
|
|
@@ -171,8 +195,11 @@ of them from the box at its top; it starts on the repository the focused
|
|
|
171
195
|
terminal's folder is a clone of. **Issues** lists, opens, edits and closes
|
|
172
196
|
issues. **Actions** shows the latest workflow runs and whether one is still
|
|
173
197
|
going, refreshing while it is on screen. **Pull requests** lists the open
|
|
174
|
-
ones. **
|
|
175
|
-
|
|
198
|
+
ones. **Branches** lists every remote branch of the selected repository, with
|
|
199
|
+
its default and protected branches marked. Filter by name, copy a branch name,
|
|
200
|
+
or open it on GitHub. Larger lists load progressively; **Refresh** checks for
|
|
201
|
+
changes while keeping your place in the list. **Repositories** lists and creates
|
|
202
|
+
repositories and clones them over SSH with a key Agent Guild keeps for each account. On a wide window the side panel
|
|
176
203
|
makes room for itself; on a smaller one it lies over the cards, and over the
|
|
177
204
|
terminals when they would get too narrow. Drag its edge to resize it. Escape
|
|
178
205
|
or **Close** puts it away.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Issues, Actions runs and open pull requests of one repository, read with a
|
|
1
|
+
// Branches, Issues, Actions runs and open pull requests of one repository, read with a
|
|
2
2
|
// signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
|
|
3
3
|
|
|
4
4
|
import { nextLink, parseRepo } from './github.mjs';
|
|
@@ -114,6 +114,42 @@ export function createViews(github) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
return {
|
|
117
|
+
async branches(accountId, owner, name, { page = '1' } = {}) {
|
|
118
|
+
if (!/^[1-9]\d*$/.test(String(page)) || !Number.isSafeInteger(Number(page))) {
|
|
119
|
+
throw refusal(400, 'bad_page', 'page must be a positive integer');
|
|
120
|
+
}
|
|
121
|
+
page = Number(page);
|
|
122
|
+
// One metadata read per listing, never a protection request per branch.
|
|
123
|
+
const [listing, metadata] = await Promise.all([
|
|
124
|
+
call(accountId, owner, name, `/branches?per_page=100&page=${page}`),
|
|
125
|
+
page === 1 ? call(accountId, owner, name, '').then(({ body }) => ({ body }), (error) => ({ error: error.message })) : null,
|
|
126
|
+
]);
|
|
127
|
+
const { body, res, repo } = listing;
|
|
128
|
+
if (!Array.isArray(body)) throw refusal(502, 'github_error', 'GitHub did not return a branch list');
|
|
129
|
+
let nextPage = null;
|
|
130
|
+
const next = nextLink(res.headers.get('link'));
|
|
131
|
+
if (next) {
|
|
132
|
+
// Reconstruct our own route; never send credentials to a supplied Link URL.
|
|
133
|
+
try { nextPage = Number(new URL(next).searchParams.get('page')); } catch { /* rejected below */ }
|
|
134
|
+
if (!Number.isSafeInteger(nextPage) || nextPage <= page) {
|
|
135
|
+
throw refusal(502, 'github_error', 'GitHub returned an invalid next page for branches');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const branches = body.filter((item) => typeof item?.name === 'string' && item.name.length > 0).map((item) => ({
|
|
139
|
+
name: item.name,
|
|
140
|
+
sha: typeof item.commit?.sha === 'string' && /^[a-f0-9]{40,64}$/i.test(item.commit.sha) ? item.commit.sha : null,
|
|
141
|
+
protected: item.protected === true,
|
|
142
|
+
url: `${WEB}/${repo.fullName}/tree/${encodeURIComponent(item.name)}`,
|
|
143
|
+
}));
|
|
144
|
+
return {
|
|
145
|
+
branches, nextPage,
|
|
146
|
+
defaultBranch: typeof metadata?.body?.default_branch === 'string' ? metadata.body.default_branch : null,
|
|
147
|
+
metadataError: metadata?.error ?? null,
|
|
148
|
+
fetchedAt: new Date().toISOString(),
|
|
149
|
+
url: `${WEB}/${repo.fullName}/branches`,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
|
|
117
153
|
async issues(accountId, owner, name, { state = 'open' } = {}) {
|
|
118
154
|
if (state !== 'open' && state !== 'closed' && state !== 'all') throw refusal(400, 'bad_state', 'state must be open, closed or all');
|
|
119
155
|
const { body, res, repo } = await call(accountId, owner, name, `/issues?state=${state}&per_page=${PAGE}&sort=updated&direction=desc`);
|
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() });
|
|
@@ -449,7 +458,7 @@ export function createManagerServer({
|
|
|
449
458
|
const folder = manager.resolveCwd(url.searchParams.get('cwd'));
|
|
450
459
|
return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
|
|
451
460
|
}
|
|
452
|
-
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
|
|
461
|
+
const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls|branches)(?:\/([^/]+))?$/);
|
|
453
462
|
if (view) {
|
|
454
463
|
let parts;
|
|
455
464
|
try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
|
|
@@ -463,6 +472,7 @@ export function createManagerServer({
|
|
|
463
472
|
if (kind === 'issues' && number !== undefined && method === 'PATCH') {
|
|
464
473
|
return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
|
|
465
474
|
}
|
|
475
|
+
if (kind === 'branches' && number === undefined && method === 'GET') return sendJson(res, 200, await views.branches(id, owner, name, { page: url.searchParams.get('page') ?? '1' }));
|
|
466
476
|
if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
|
|
467
477
|
if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
|
|
468
478
|
}
|
|
@@ -544,6 +554,8 @@ export function createManagerServer({
|
|
|
544
554
|
}
|
|
545
555
|
manager.on('event', broadcast);
|
|
546
556
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
557
|
+
const environmentUpdated = () => broadcast({ type: 'environment.updated', environment: environment.snapshot() });
|
|
558
|
+
environment?.on('updated', environmentUpdated);
|
|
547
559
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
548
560
|
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
549
561
|
changelog?.on('updated', () => broadcast({ type: 'changelog.updated' }));
|
|
@@ -650,6 +662,7 @@ export function createManagerServer({
|
|
|
650
662
|
},
|
|
651
663
|
/** @param {{ notice?: object }} [opts] a final event for the events clients */
|
|
652
664
|
async close({ notice } = {}) {
|
|
665
|
+
environment?.off('updated', environmentUpdated);
|
|
653
666
|
if (notice) await farewell(notice);
|
|
654
667
|
clearInterval(heartbeat);
|
|
655
668
|
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);
|
|
@@ -2965,6 +2967,13 @@ function loadGitHub() {
|
|
|
2965
2967
|
function setGitHub(github) {
|
|
2966
2968
|
const before = state.github?.signIn;
|
|
2967
2969
|
state.github = github;
|
|
2970
|
+
const branches = githubPick.data.branches;
|
|
2971
|
+
if (branches && !github.accounts.some((a) => a.id === branches.repo.accountId && !a.needsSignIn)) {
|
|
2972
|
+
// Replace the generation even while the dock is closed; signing in again must
|
|
2973
|
+
// never make an earlier account's pending answer current.
|
|
2974
|
+
githubPick.data.branches = { ...branches, value: null, loading: false, nextPage: null,
|
|
2975
|
+
error: `Sign in as @${branches.repo.login} in Repositories to see its branches.` };
|
|
2976
|
+
}
|
|
2968
2977
|
const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
|
|
2969
2978
|
if (done) {
|
|
2970
2979
|
selectGitHubAccount(done.accountId);
|
|
@@ -3084,7 +3093,7 @@ function openGitHub({ focus = true } = {}) {
|
|
|
3084
3093
|
loadGitHub();
|
|
3085
3094
|
if (githubPick.repo) {
|
|
3086
3095
|
loadView('actions');
|
|
3087
|
-
if (
|
|
3096
|
+
if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
|
|
3088
3097
|
}
|
|
3089
3098
|
followTerminal();
|
|
3090
3099
|
if (focus) ($('github-card').querySelector('.btn.primary') ?? $('dock-close')).focus();
|
|
@@ -3495,7 +3504,7 @@ function useFolder(dir) {
|
|
|
3495
3504
|
|
|
3496
3505
|
// ---- GitHub repository views ------------------------------------------------
|
|
3497
3506
|
|
|
3498
|
-
const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls'];
|
|
3507
|
+
const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls', 'branches'];
|
|
3499
3508
|
const RUNS_POLL_MS = 6000;
|
|
3500
3509
|
const RUNS_IDLE_POLL_MS = 30000;
|
|
3501
3510
|
const BODY_LIMIT = 48000;
|
|
@@ -3590,7 +3599,11 @@ function pickRepo(repo, { chosen = false } = {}) {
|
|
|
3590
3599
|
githubPick.recent = remember(githubPick.recent, repo);
|
|
3591
3600
|
save(GITHUB_RECENT_KEY, JSON.stringify(githubPick.recent));
|
|
3592
3601
|
}
|
|
3593
|
-
if (!same)
|
|
3602
|
+
if (!same) {
|
|
3603
|
+
Object.assign(githubPick, { data: {}, editing: null });
|
|
3604
|
+
$('github-branches-filter').value = '';
|
|
3605
|
+
$('github-branches').scrollTop = 0;
|
|
3606
|
+
}
|
|
3594
3607
|
if ((!same || chosen) && githubView.accountId !== repo.accountId && state.github?.accounts.some((a) => a.id === repo.accountId)) {
|
|
3595
3608
|
selectGitHubAccount(repo.accountId);
|
|
3596
3609
|
githubView.card = null;
|
|
@@ -3603,7 +3616,7 @@ function pickRepo(repo, { chosen = false } = {}) {
|
|
|
3603
3616
|
renderGitHubViews();
|
|
3604
3617
|
if (!dockShows('github')) return;
|
|
3605
3618
|
loadView('actions');
|
|
3606
|
-
if (
|
|
3619
|
+
if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
|
|
3607
3620
|
}
|
|
3608
3621
|
|
|
3609
3622
|
/** Picks the repository whose clone holds the focused terminal's folder, once for each terminal focused. */
|
|
@@ -3626,6 +3639,7 @@ async function followTerminal() {
|
|
|
3626
3639
|
}
|
|
3627
3640
|
|
|
3628
3641
|
async function loadView(view) {
|
|
3642
|
+
if (view === 'branches') return loadBranches();
|
|
3629
3643
|
const repo = githubPick.repo;
|
|
3630
3644
|
if (!repo || view === 'repos') return;
|
|
3631
3645
|
const query = view === 'issues' ? `?state=${githubPick.issueState}` : '';
|
|
@@ -3650,6 +3664,170 @@ async function loadView(view) {
|
|
|
3650
3664
|
if (view === 'actions') scheduleRuns();
|
|
3651
3665
|
}
|
|
3652
3666
|
|
|
3667
|
+
// Each object is a listing generation, scoped to the selected account/repository.
|
|
3668
|
+
// A retry continues that generation; Refresh replaces it and starts at page one.
|
|
3669
|
+
function branchesCurrent(request) {
|
|
3670
|
+
return githubPick.data.branches === request && githubPick.repo && repoKey(githubPick.repo) === request.key
|
|
3671
|
+
&& state.github?.accounts.some((a) => a.id === request.repo.accountId && !a.needsSignIn);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
function branchesVisible() {
|
|
3675
|
+
return dockShows('github') && githubShownView() === 'branches' && document.visibilityState === 'visible';
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
async function loadBranches({ resume = false } = {}) {
|
|
3679
|
+
const repo = githubPick.repo;
|
|
3680
|
+
if (!repo || !state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn)) return;
|
|
3681
|
+
const before = viewData('branches');
|
|
3682
|
+
if (resume && (!before || before.loading || before.nextPage === null)) return;
|
|
3683
|
+
const request = resume ? before : {
|
|
3684
|
+
key: repoKey(repo), repo, value: before?.value ?? null, loading: false, error: null,
|
|
3685
|
+
nextPage: 1, received: false, rows: new Map(), defaultBranch: null, metadataError: null,
|
|
3686
|
+
};
|
|
3687
|
+
githubPick.data.branches = request;
|
|
3688
|
+
request.loading = true;
|
|
3689
|
+
request.error = null;
|
|
3690
|
+
renderGitHubViews();
|
|
3691
|
+
try {
|
|
3692
|
+
while (request.nextPage !== null && branchesVisible() && branchesCurrent(request)) {
|
|
3693
|
+
const page = request.nextPage;
|
|
3694
|
+
const result = await api('GET', `${repoPath(repo)}/branches?page=${page}`);
|
|
3695
|
+
if (!branchesCurrent(request)) return;
|
|
3696
|
+
// A malformed response must not masquerade as a complete, empty list.
|
|
3697
|
+
if (!Array.isArray(result.branches) || (result.nextPage !== null
|
|
3698
|
+
&& (!Number.isSafeInteger(result.nextPage) || result.nextPage <= page))) {
|
|
3699
|
+
throw new Error('Could not read the next page of branches. Try again.');
|
|
3700
|
+
}
|
|
3701
|
+
if (page === 1) {
|
|
3702
|
+
request.defaultBranch = result.defaultBranch;
|
|
3703
|
+
request.metadataError = result.metadataError;
|
|
3704
|
+
}
|
|
3705
|
+
for (const branch of result.branches) request.rows.set(branch.name, branch);
|
|
3706
|
+
request.nextPage = result.nextPage;
|
|
3707
|
+
request.received = true;
|
|
3708
|
+
request.value = { ...result, branches: [...request.rows.values()],
|
|
3709
|
+
defaultBranch: request.defaultBranch, metadataError: request.metadataError };
|
|
3710
|
+
renderGitHubViews();
|
|
3711
|
+
}
|
|
3712
|
+
} catch (err) {
|
|
3713
|
+
if (!branchesCurrent(request)) return;
|
|
3714
|
+
if (err instanceof AuthError) { showAuth(err.message); return; }
|
|
3715
|
+
request.error = err.message;
|
|
3716
|
+
} finally {
|
|
3717
|
+
if (branchesCurrent(request)) {
|
|
3718
|
+
request.loading = false;
|
|
3719
|
+
renderGitHubViews();
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
/** Keep the first visible branch at the same pixel offset when earlier rows arrive. */
|
|
3725
|
+
function branchAnchor(panel, list) {
|
|
3726
|
+
const { top, bottom } = panel.getBoundingClientRect();
|
|
3727
|
+
const row = [...list.children].find((node) => {
|
|
3728
|
+
const rect = node.getBoundingClientRect();
|
|
3729
|
+
return rect.bottom > top && rect.top < bottom;
|
|
3730
|
+
});
|
|
3731
|
+
return row ? { name: row.dataset.branch, offset: row.getBoundingClientRect().top - top } : null;
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
async function copyBranch(name, control) {
|
|
3735
|
+
const request = viewData('branches');
|
|
3736
|
+
control.disabled = true;
|
|
3737
|
+
let message;
|
|
3738
|
+
try {
|
|
3739
|
+
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
|
|
3740
|
+
await navigator.clipboard.writeText(name);
|
|
3741
|
+
message = `Copied branch name: ${name}`;
|
|
3742
|
+
} catch {
|
|
3743
|
+
message = 'Could not copy. Select the branch name and copy it using your browser.';
|
|
3744
|
+
} finally {
|
|
3745
|
+
control.disabled = false;
|
|
3746
|
+
}
|
|
3747
|
+
if (request === viewData('branches') && branchesVisible() && control.isConnected) toast(message);
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
function renderBranches({ preserveAnchor = true } = {}) {
|
|
3751
|
+
const panel = $('github-branches');
|
|
3752
|
+
const list = $('github-branches-list');
|
|
3753
|
+
const repo = githubPick.repo;
|
|
3754
|
+
const key = repo ? repoKey(repo) : '';
|
|
3755
|
+
const same = panel.dataset.repo === key;
|
|
3756
|
+
const anchor = same && preserveAnchor ? branchAnchor(panel, list) : null;
|
|
3757
|
+
const focused = list.contains(document.activeElement) ? document.activeElement : null;
|
|
3758
|
+
const oldScroll = same ? panel.scrollTop : 0;
|
|
3759
|
+
if (!same) { list.replaceChildren(); panel.dataset.repo = key; }
|
|
3760
|
+
$('github-branches-pick').hidden = Boolean(repo);
|
|
3761
|
+
$('github-branches-content').hidden = !repo;
|
|
3762
|
+
if (!repo) return;
|
|
3763
|
+
const slot = viewData('branches');
|
|
3764
|
+
const value = slot?.value;
|
|
3765
|
+
const query = $('github-branches-filter').value.trim().toLocaleLowerCase();
|
|
3766
|
+
$('github-branches-clear').disabled = !$('github-branches-filter').value;
|
|
3767
|
+
const refresh = $('github-branches-refresh');
|
|
3768
|
+
const usable = state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn);
|
|
3769
|
+
refresh.disabled = Boolean(slot?.loading) || !usable;
|
|
3770
|
+
refresh.textContent = slot?.loading ? (value ? 'Refreshing…' : 'Loading…') : 'Refresh';
|
|
3771
|
+
const out = $('github-branches-out');
|
|
3772
|
+
out.href = value?.url ?? `https://github.com/${repo.fullName}/branches`;
|
|
3773
|
+
out.title = `Open ${repo.fullName} branches on GitHub`;
|
|
3774
|
+
const branches = (value?.branches ?? []).filter((b) => b.name.toLocaleLowerCase().includes(query)).sort((a, b) =>
|
|
3775
|
+
Number(b.name === value.defaultBranch) - Number(a.name === value.defaultBranch)
|
|
3776
|
+
|| a.name.localeCompare(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
3777
|
+
const complete = Boolean(value && value.nextPage === null && (!slot.error || slot.received));
|
|
3778
|
+
const count = value?.branches.length ?? 0;
|
|
3779
|
+
let status = !value ? (slot?.error ? 'Branches could not be loaded.' : 'Loading branches…')
|
|
3780
|
+
: query ? `${branches.length} of ${count} loaded branches match.`
|
|
3781
|
+
: `${count} ${count === 1 ? 'branch' : 'branches'}${complete ? '' : ' loaded'}.`;
|
|
3782
|
+
if (value && !slot.received) status += slot.loading ? ' Refreshing…' : ' Showing previous results.';
|
|
3783
|
+
else if (!complete && value) status += slot.loading ? ' Loading more…' : ' Listing incomplete.';
|
|
3784
|
+
if (complete && !branches.length) status = query ? `No branches match “${$('github-branches-filter').value.trim()}”.` : 'No remote branches yet.';
|
|
3785
|
+
const statusNode = $('github-branches-status');
|
|
3786
|
+
if (statusNode.textContent !== status) statusNode.textContent = status;
|
|
3787
|
+
const failure = $('github-branches-error');
|
|
3788
|
+
failure.hidden = !slot?.error;
|
|
3789
|
+
failure.querySelector('span').textContent = slot?.error ?? '';
|
|
3790
|
+
$('github-branches-retry').disabled = Boolean(slot?.loading) || slot?.nextPage === null || !usable;
|
|
3791
|
+
const metadata = $('github-branches-metadata');
|
|
3792
|
+
metadata.hidden = !value?.metadataError;
|
|
3793
|
+
metadata.textContent = value?.metadataError ? 'Branches loaded, but the default branch could not be identified. Refresh to try again.' : '';
|
|
3794
|
+
const existing = new Map([...list.children].map((row) => [row.dataset.branch, row]));
|
|
3795
|
+
const wanted = new Set(branches.map((branch) => branch.name));
|
|
3796
|
+
let cursor = list.firstElementChild;
|
|
3797
|
+
for (const branch of branches) {
|
|
3798
|
+
let row = existing.get(branch.name);
|
|
3799
|
+
if (!row) {
|
|
3800
|
+
const copy = button('Copy name', () => copyBranch(branch.name, copy));
|
|
3801
|
+
copy.setAttribute('aria-label', `Copy branch name ${branch.name}`);
|
|
3802
|
+
row = recordRow(`branch:${branch.name}`, branch.name, '', copy, branch.url);
|
|
3803
|
+
row.classList.add('github-branch');
|
|
3804
|
+
row.dataset.branch = branch.name;
|
|
3805
|
+
row.setAttribute('role', 'listitem');
|
|
3806
|
+
row.querySelector('.history-title').append(el('span', 'github-branch-badges'));
|
|
3807
|
+
}
|
|
3808
|
+
const badges = row.querySelector('.github-branch-badges');
|
|
3809
|
+
const badgeState = `${branch.name === value.defaultBranch}:${branch.protected}`;
|
|
3810
|
+
if (badges.dataset.state !== badgeState) {
|
|
3811
|
+
badges.dataset.state = badgeState;
|
|
3812
|
+
badges.replaceChildren(...[
|
|
3813
|
+
branch.name === value.defaultBranch ? badge('', 'Default', 'Default branch') : null,
|
|
3814
|
+
branch.protected ? badge('', 'Protected', 'Protected by branch protection or rulesets') : null,
|
|
3815
|
+
].filter(Boolean));
|
|
3816
|
+
}
|
|
3817
|
+
row.querySelector('.history-meta').textContent = branch.sha ? branch.sha.slice(0, 7) : '';
|
|
3818
|
+
row.querySelector('.history-meta').title = branch.sha ?? '';
|
|
3819
|
+
if (row === cursor) cursor = cursor.nextElementSibling;
|
|
3820
|
+
else list.insertBefore(row, cursor);
|
|
3821
|
+
}
|
|
3822
|
+
for (const row of [...list.children]) if (!wanted.has(row.dataset.branch)) row.remove();
|
|
3823
|
+
if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
|
|
3824
|
+
panel.scrollTop = oldScroll;
|
|
3825
|
+
if (anchor) {
|
|
3826
|
+
const row = [...list.children].find((node) => node.dataset.branch === anchor.name);
|
|
3827
|
+
if (row) panel.scrollTop += row.getBoundingClientRect().top - panel.getBoundingClientRect().top - anchor.offset;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3653
3831
|
function viewData(view) {
|
|
3654
3832
|
const slot = githubPick.data[view];
|
|
3655
3833
|
const repo = githubPick.repo;
|
|
@@ -3822,6 +4000,7 @@ function renderGitHubViews() {
|
|
|
3822
4000
|
$('github-issues').hidden = shown !== 'issues';
|
|
3823
4001
|
$('github-runs').hidden = shown !== 'actions';
|
|
3824
4002
|
$('github-pulls').hidden = shown !== 'pulls';
|
|
4003
|
+
$('github-branches').hidden = shown !== 'branches';
|
|
3825
4004
|
const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
|
|
3826
4005
|
$('github-clone-into').hidden = !ready || shown !== 'repos';
|
|
3827
4006
|
const running = Boolean(viewData('actions')?.value?.running);
|
|
@@ -3831,6 +4010,7 @@ function renderGitHubViews() {
|
|
|
3831
4010
|
if (shown === 'issues') renderIssues();
|
|
3832
4011
|
else if (shown === 'actions') renderRuns();
|
|
3833
4012
|
else if (shown === 'pulls') renderPulls();
|
|
4013
|
+
else if (shown === 'branches') renderBranches();
|
|
3834
4014
|
}
|
|
3835
4015
|
|
|
3836
4016
|
/** Rebuilds a panel and puts the focus back on the control with the same `data-key`. */
|
|
@@ -5379,6 +5559,7 @@ function connectEvents() {
|
|
|
5379
5559
|
state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
|
|
5380
5560
|
for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
|
|
5381
5561
|
managerConnected(msg);
|
|
5562
|
+
state.environmentUI?.connected(msg.pid);
|
|
5382
5563
|
renderSessions();
|
|
5383
5564
|
if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
|
|
5384
5565
|
restorePanes();
|
|
@@ -5391,6 +5572,8 @@ function connectEvents() {
|
|
|
5391
5572
|
catchUpNotes(msg.notesRevision, msg.notesUnreadable === true);
|
|
5392
5573
|
} else if (msg.type === 'notes.updated') {
|
|
5393
5574
|
applyServerNotes(msg.notes);
|
|
5575
|
+
} else if (msg.type === 'environment.updated') {
|
|
5576
|
+
state.environmentUI?.updated(msg.environment);
|
|
5394
5577
|
} else if (msg.type === 'remote-access.updated') {
|
|
5395
5578
|
state.remoteAccessUI?.updated();
|
|
5396
5579
|
} else if (msg.type === 'news.updated') {
|
|
@@ -5421,6 +5604,7 @@ function connectEvents() {
|
|
|
5421
5604
|
};
|
|
5422
5605
|
ws.onclose = (event) => {
|
|
5423
5606
|
if (state.pageAway || state.eventsSocket !== ws) return;
|
|
5607
|
+
state.environmentUI?.disconnected();
|
|
5424
5608
|
if (event?.code === 4403) {
|
|
5425
5609
|
state.remoteRevoked = true;
|
|
5426
5610
|
managerLoss.cancel();
|
|
@@ -5469,6 +5653,7 @@ let statsInterval;
|
|
|
5469
5653
|
let newsTimer;
|
|
5470
5654
|
|
|
5471
5655
|
function showAuth(message = '') {
|
|
5656
|
+
state.environmentUI?.close();
|
|
5472
5657
|
state.remoteAccessUI?.setAvailable(null);
|
|
5473
5658
|
managerLoss.cancel();
|
|
5474
5659
|
closePanel();
|
|
@@ -5643,6 +5828,18 @@ $('github-views').addEventListener('click', (e) => {
|
|
|
5643
5828
|
if (view) switchGitHubView(view);
|
|
5644
5829
|
});
|
|
5645
5830
|
$('github-views').addEventListener('keydown', moveGitHubView);
|
|
5831
|
+
$('github-branches-refresh').addEventListener('click', () => loadView('branches'));
|
|
5832
|
+
$('github-branches-retry').addEventListener('click', () => loadBranches({ resume: true }));
|
|
5833
|
+
$('github-branches-filter').addEventListener('input', () => {
|
|
5834
|
+
$('github-branches').scrollTop = 0;
|
|
5835
|
+
renderBranches({ preserveAnchor: false });
|
|
5836
|
+
});
|
|
5837
|
+
$('github-branches-clear').addEventListener('click', () => {
|
|
5838
|
+
$('github-branches-filter').value = '';
|
|
5839
|
+
$('github-branches').scrollTop = 0;
|
|
5840
|
+
renderBranches({ preserveAnchor: false });
|
|
5841
|
+
$('github-branches-filter').focus();
|
|
5842
|
+
});
|
|
5646
5843
|
$('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
|
|
5647
5844
|
$('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
|
|
5648
5845
|
$('history-here').addEventListener('change', renderHistory);
|
|
@@ -5703,6 +5900,7 @@ document.addEventListener('visibilitychange', () => {
|
|
|
5703
5900
|
if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
|
|
5704
5901
|
if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
|
|
5705
5902
|
else scheduleRuns();
|
|
5903
|
+
if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
|
|
5706
5904
|
});
|
|
5707
5905
|
addEventListener('pagehide', () => {
|
|
5708
5906
|
flushNotes();
|
|
@@ -5924,5 +6122,6 @@ publishTopbarHeight();
|
|
|
5924
6122
|
applyDockLayout();
|
|
5925
6123
|
|
|
5926
6124
|
state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
|
|
6125
|
+
state.environmentUI = createEnvironmentUI({ api, onAuthError: showAuth, isAuthError: (err) => err instanceof AuthError });
|
|
5927
6126
|
state.token = readTokenFromHash() || load(TOKEN_KEY);
|
|
5928
6127
|
boot();
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
// A queued close from an earlier opening must not clear the current opener.
|
|
96
|
+
if (dialog.open) return;
|
|
97
|
+
if (openerId) document.querySelector(`.provider[data-id="${openerId}"] .environment-open`)?.focus();
|
|
98
|
+
openerId = null;
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
updated,
|
|
102
|
+
connected(pid) {
|
|
103
|
+
if (pid !== managerPid) { data = null; managerPid = pid; }
|
|
104
|
+
request++; pending = false; online = true;
|
|
105
|
+
load();
|
|
106
|
+
},
|
|
107
|
+
disconnected() {
|
|
108
|
+
request++; pending = false; online = false;
|
|
109
|
+
error = 'Disconnected. Results are from the last check.';
|
|
110
|
+
render();
|
|
111
|
+
},
|
|
112
|
+
close() { openerId = null; dialog.close(); this.disconnected(); },
|
|
113
|
+
renderCard(card, provider) {
|
|
114
|
+
const host = card.querySelector('.environment-summary');
|
|
115
|
+
host.hidden = !Array.isArray(provider.shells);
|
|
116
|
+
if (host.hidden) return;
|
|
117
|
+
const button = host.querySelector('.environment-open');
|
|
118
|
+
button.dataset.muxFocus = `${provider.id}:environment`;
|
|
119
|
+
button.addEventListener('click', () => {
|
|
120
|
+
openerId = provider.id;
|
|
121
|
+
render();
|
|
122
|
+
if (!dialog.open) dialog.showModal();
|
|
123
|
+
});
|
|
124
|
+
renderSummary(host);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/web/index.html
CHANGED
|
@@ -208,6 +208,7 @@
|
|
|
208
208
|
<button id="github-view-issues" class="github-view" type="button" role="tab" aria-controls="github-issues" aria-selected="false" data-view="issues" tabindex="-1">Issues</button>
|
|
209
209
|
<button id="github-view-actions" class="github-view" type="button" role="tab" aria-controls="github-runs" aria-selected="false" data-view="actions" tabindex="-1" aria-label="Actions">Actions<span id="github-running" class="github-running" hidden></span></button>
|
|
210
210
|
<button id="github-view-pulls" class="github-view" type="button" role="tab" aria-controls="github-pulls" aria-selected="false" data-view="pulls" tabindex="-1" aria-label="Pull requests"><span class="long">Pull requests</span><span class="short" aria-hidden="true">PRs</span></button>
|
|
211
|
+
<button id="github-view-branches" class="github-view" type="button" role="tab" aria-controls="github-branches" aria-selected="false" data-view="branches" tabindex="-1">Branches</button>
|
|
211
212
|
</div>
|
|
212
213
|
<div id="github-repos" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-repos">
|
|
213
214
|
<section id="github-card" class="github-card" aria-live="polite" hidden></section>
|
|
@@ -241,6 +242,27 @@
|
|
|
241
242
|
<div id="github-issues" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-issues" hidden></div>
|
|
242
243
|
<div id="github-runs" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-actions" hidden></div>
|
|
243
244
|
<div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
|
|
245
|
+
<div id="github-branches" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-branches" hidden>
|
|
246
|
+
<p id="github-branches-pick" class="github-view-note">Pick a repository above to see its branches.</p>
|
|
247
|
+
<div id="github-branches-content" hidden>
|
|
248
|
+
<div class="github-view-tools">
|
|
249
|
+
<button id="github-branches-refresh" class="btn" type="button">Refresh</button>
|
|
250
|
+
<span class="github-spacer"></span>
|
|
251
|
+
<a id="github-branches-out" class="btn github-out" target="_blank" rel="noopener noreferrer" aria-description="Opens in a new tab">GitHub</a>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="github-branch-filter">
|
|
254
|
+
<input id="github-branches-filter" type="search" placeholder="Filter branches" aria-label="Filter branches" spellcheck="false" autocomplete="off">
|
|
255
|
+
<button id="github-branches-clear" class="btn" type="button" disabled>Clear</button>
|
|
256
|
+
</div>
|
|
257
|
+
<p id="github-branches-status" class="github-view-note" role="status" aria-live="polite" aria-atomic="true"></p>
|
|
258
|
+
<div id="github-branches-error" class="github-error" hidden>
|
|
259
|
+
<span role="alert"></span>
|
|
260
|
+
<button id="github-branches-retry" class="btn" type="button">Retry</button>
|
|
261
|
+
</div>
|
|
262
|
+
<p id="github-branches-metadata" class="github-view-note" hidden></p>
|
|
263
|
+
<div id="github-branches-list" class="history-list" role="list" aria-label="Remote branches"></div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
244
266
|
<div id="github-clone-into" class="history-form github-form">
|
|
245
267
|
<label for="github-parent">Clone into</label>
|
|
246
268
|
<div class="cwd-field single">
|
|
@@ -344,6 +366,28 @@
|
|
|
344
366
|
</div>
|
|
345
367
|
</dialog>
|
|
346
368
|
|
|
369
|
+
<dialog id="environment" class="models environment-dialog" aria-labelledby="environment-title">
|
|
370
|
+
<div class="models-bar">
|
|
371
|
+
<div class="models-heading">
|
|
372
|
+
<h2 id="environment-title">Manager environment</h2>
|
|
373
|
+
<p class="sub">On the computer running Agent Guild</p>
|
|
374
|
+
</div>
|
|
375
|
+
<button id="environment-close" class="btn" type="button">Close</button>
|
|
376
|
+
</div>
|
|
377
|
+
<div class="models-body">
|
|
378
|
+
<p class="environment-detail">Versions available to the manager. Shell profiles, projects and existing sessions can use different versions.</p>
|
|
379
|
+
<p class="environment-detail">Python uses <code>python</code> when found, otherwise <code>python3</code>. A failed <code>python</code> check stays primary.</p>
|
|
380
|
+
<p id="environment-status" class="environment-detail" role="status"></p>
|
|
381
|
+
<button id="environment-refresh" class="btn" type="button">Refresh</button>
|
|
382
|
+
<div id="environment-runtimes"></div>
|
|
383
|
+
<h3>Detected tools</h3>
|
|
384
|
+
<p class="environment-detail">Presence only. Activation and the runtimes these tools manage are not checked.</p>
|
|
385
|
+
<div id="environment-tools"></div>
|
|
386
|
+
<p id="environment-tools-empty" class="environment-detail"></p>
|
|
387
|
+
<p id="environment-manager-node" class="environment-detail"></p>
|
|
388
|
+
</div>
|
|
389
|
+
</dialog>
|
|
390
|
+
|
|
347
391
|
<dialog id="models" class="models" aria-labelledby="models-title">
|
|
348
392
|
<div class="models-bar">
|
|
349
393
|
<span id="models-icon" class="provider-icon small"></span>
|
|
@@ -475,6 +519,11 @@
|
|
|
475
519
|
</div>
|
|
476
520
|
<div class="accounts" role="tablist" aria-label="Accounts" hidden></div>
|
|
477
521
|
<div class="shells" role="tablist" aria-label="Shells" hidden></div>
|
|
522
|
+
<div class="environment-summary" hidden>
|
|
523
|
+
<button class="environment-open" type="button" aria-haspopup="dialog">Environment details</button>
|
|
524
|
+
<dl class="environment-values"></dl>
|
|
525
|
+
<p class="environment-note"></p>
|
|
526
|
+
</div>
|
|
478
527
|
<div class="usage"></div>
|
|
479
528
|
<div class="model-stats"></div>
|
|
480
529
|
<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; }
|
|
@@ -884,10 +898,27 @@ a.news-link:visited { color: var(--muted); }
|
|
|
884
898
|
.github-view[aria-selected="true"] { color: var(--heading); background: var(--surface-2); border-color: var(--border); border-bottom-color: var(--surface-2); }
|
|
885
899
|
.github-view:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
|
886
900
|
.github-view .short { display: none; }
|
|
887
|
-
@container (max-width:
|
|
901
|
+
@container (max-width: 440px) {
|
|
888
902
|
.github-view .long { display: none; }
|
|
889
903
|
.github-view .short { display: inline; }
|
|
890
904
|
}
|
|
905
|
+
#github-branches { overflow-anchor: none; }
|
|
906
|
+
.github-branch-filter { display: flex; align-items: center; gap: 6px; }
|
|
907
|
+
.github-branch-filter input {
|
|
908
|
+
flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius);
|
|
909
|
+
background: var(--surface); color: var(--text); font: inherit; font-size: 0.85rem;
|
|
910
|
+
}
|
|
911
|
+
.github-branch-filter input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
912
|
+
.github-branch .history-title { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
|
|
913
|
+
.github-branch .github-record-title { min-width: 0; overflow-wrap: anywhere; }
|
|
914
|
+
.github-branch-badges { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
915
|
+
.github-branch .history-meta { font-family: var(--font-mono, monospace); }
|
|
916
|
+
.github-branch > .btn { align-self: start; }
|
|
917
|
+
#github-branches-error .btn { margin-left: 6px; }
|
|
918
|
+
@media (pointer: coarse) {
|
|
919
|
+
.github-branch-filter input { font-size: 16px; }
|
|
920
|
+
.github-branch > .btn, #github-branches .github-view-tools .btn, .github-branch-filter .btn { min-height: 36px; }
|
|
921
|
+
}
|
|
891
922
|
.github-running { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); animation: pulse 1.4s ease-in-out infinite; }
|
|
892
923
|
.github-body[hidden] { display: none; }
|
|
893
924
|
.github-view-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-bottom: 6px; }
|