@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 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. **Repositories** lists and creates repositories and clones them over SSH
175
- with a key Agent Guild keeps for each account. On a wide window the side panel
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.30.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`);
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { ProviderRegistry } from './providers.mjs';
8
+ import { Environment } from './environment.mjs';
8
9
  import { SessionManager } from './session-manager.mjs';
9
10
  import { UsageMonitor } from './usage.mjs';
10
11
  import { SessionHistory } from './session-history.mjs';
@@ -46,6 +47,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
46
47
  ensureDataDir();
47
48
  const token = loadOrCreateToken();
48
49
  const baseEnv = resolveBaseEnv();
50
+ const environment = new Environment({ env: baseEnv });
49
51
  const remoteAccess = new RemoteAccess({ file: paths.remoteAccess, env: baseEnv, loaded: remoteSettings, ...remoteOptions });
50
52
  const webDir = path.join(rootDir, 'web');
51
53
  // The hooks in examples/ call `agent-guild-report` by name; these shims
@@ -98,6 +100,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
98
100
  if (closing) return closing;
99
101
  console.log(`[manager] ${restart ? 'restarting' : 'stopping'} (${reason}); ending ${manager.sessions.size} session(s)`);
100
102
  clearInterval(versionTimer);
103
+ environment.close();
101
104
  remoteAccess.close();
102
105
  github.close();
103
106
  removeRuntimeFile();
@@ -124,6 +127,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
124
127
  api = createManagerServer({
125
128
  manager,
126
129
  registry,
130
+ environment,
127
131
  usage,
128
132
  history,
129
133
  modelStats,
@@ -104,6 +104,7 @@ export function createManagerServer({
104
104
  modelStats,
105
105
  news = null,
106
106
  changelog = null,
107
+ environment = null,
107
108
  github = null,
108
109
  token,
109
110
  host = '127.0.0.1',
@@ -307,6 +308,14 @@ export function createManagerServer({
307
308
  const session = await manager.upgrade();
308
309
  return sendJson(res, 201, { session: session.toJSON() });
309
310
  }
311
+ if (route === '/environment' && method === 'GET' && environment) {
312
+ if (!environment.snapshot().checkedAt) environment.refresh();
313
+ return sendJson(res, 200, environment.snapshot());
314
+ }
315
+ if (route === '/environment/refresh' && method === 'POST' && environment) {
316
+ await readJsonBody(req);
317
+ return sendJson(res, 202, environment.refresh());
318
+ }
310
319
  if (route === '/providers' && method === 'GET') {
311
320
  registry.refreshVersions().catch(() => {});
312
321
  return sendJson(res, 200, { providers: registry.list() });
@@ -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 (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
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) Object.assign(githubPick, { data: {}, editing: null });
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 (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
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: 340px) {
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; }