@oddessentials/agent-guild 0.2.0 → 0.3.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 +20 -7
- package/config/providers.default.json +10 -0
- package/package.json +1 -1
- package/src/manager/config.mjs +7 -1
- package/src/manager/main.mjs +17 -7
- package/src/manager/providers.mjs +89 -5
- package/src/manager/self-update.mjs +205 -0
- package/src/manager/server.mjs +10 -1
- package/src/manager/session-manager.mjs +58 -7
- package/src/manager/session.mjs +4 -1
- package/src/manager/usage.mjs +106 -25
- package/web/app.js +177 -12
- package/web/art/art.css +8 -6
- package/web/index.html +5 -0
- package/web/styles.css +29 -18
- package/web/theme.js +10 -0
package/README.md
CHANGED
|
@@ -36,6 +36,12 @@ coding tool, and see at a glance which sessions and agents are working.
|
|
|
36
36
|
not survive a computer restart. **Stop manager** in the top bar stops the
|
|
37
37
|
manager and ends every session; it asks first while any session is still
|
|
38
38
|
running.
|
|
39
|
+
* **Stay current.** When a newer Agent Guild is on npm, an **Upgrade**
|
|
40
|
+
button appears in the top bar and runs `npm install -g` in a session you
|
|
41
|
+
can watch. Sessions keep running; stop the manager and run `agent-guild
|
|
42
|
+
open` to use the new version.
|
|
43
|
+
* **Light or dark.** The page follows your system theme and the top-bar
|
|
44
|
+
toggle switches it. The guild artwork is the dark theme.
|
|
39
45
|
|
|
40
46
|
## Requirements
|
|
41
47
|
|
|
@@ -95,6 +101,10 @@ one platform under a `win32` or `darwin` key. See
|
|
|
95
101
|
| `modelPattern` | Regular expression that finds the model name on the tool's screen when the tool does not report it. |
|
|
96
102
|
| `resumeArgs` | Arguments that resume the tool's own session, with `{id}` standing for the id, e.g. `["--resume", "{id}"]`. Without it the card has no **Existing** button. |
|
|
97
103
|
| `env` | Extra environment variables for the tool. |
|
|
104
|
+
| `accounts` | Further sign-ins of the tool, each in its own home folder, e.g. `[{ "id": "work", "label": "Work", "dir": "~/.claude-work" }]`. Without `dir`, the folder is `accounts/<provider>/<account>` in the data folder. The card shows one chip per account with its own meters, and a session starts under the chip picked; the tool signs in from inside the first session, and its reporting hooks are copied into the folder on first use. An entry with id `default` renames the tool's own sign-in. Needs `homeVar`. |
|
|
105
|
+
| `homeVar` | The environment variable that moves the tool's home folder, e.g. `CLAUDE_CONFIG_DIR`. Set for Claude Code, Codex CLI, Gemini CLI and Grok Build by default. |
|
|
106
|
+
| `accountEnv` | Further variables set for every account other than the default, with `{dir}` standing for the account's folder. By default Claude Code's secure-storage folder follows the account, and Gemini CLI keeps the account's sign-in in a file rather than the shared OS keychain. |
|
|
107
|
+
| `hooks` | `{ "path", "example" }`: the hooks file inside the home folder and the file in `examples/` copied there for a new account. |
|
|
98
108
|
| `color`, `monogram`, `icon` | Icon appearance. `icon` is a URL path; you can also drop `<id>.svg` into `web/icons/`. |
|
|
99
109
|
| `install`, `docs` | Help shown when the tool is not installed. |
|
|
100
110
|
| `usageUrl`, `billingUrl` | `https://` links to the vendor's usage and billing pages, shown on the card. The defaults point at the subscription pages; set your API console instead, or `null` to hide a link. Google's usage link opens AI Studio, which counts API-key usage only, not the Gemini CLI sign-in quota the card's meters show. |
|
|
@@ -102,10 +112,11 @@ one platform under a `win32` or `darwin` key. See
|
|
|
102
112
|
The page's "Working folder" field sets where new sessions start. It defaults
|
|
103
113
|
to your home folder.
|
|
104
114
|
|
|
105
|
-
Version checks
|
|
106
|
-
`npm install -g` uses) about once
|
|
107
|
-
registry. Set `AGENT_GUILD_NPM_REGISTRY`
|
|
108
|
-
`AGENT_GUILD_NO_UPDATE_CHECK=1` to skip the
|
|
115
|
+
Version checks, for the tools and for Agent Guild itself, ask the registry
|
|
116
|
+
from npm's global configuration (the one `npm install -g` uses) about once
|
|
117
|
+
an hour, and installs use the same registry. Set `AGENT_GUILD_NPM_REGISTRY`
|
|
118
|
+
to override it for both, or `AGENT_GUILD_NO_UPDATE_CHECK=1` to skip the
|
|
119
|
+
checks.
|
|
109
120
|
|
|
110
121
|
## Show agents and models
|
|
111
122
|
|
|
@@ -160,6 +171,8 @@ coding tool in `tests/fixtures`. CI runs them on Windows, macOS and Linux.
|
|
|
160
171
|
## Current limits
|
|
161
172
|
|
|
162
173
|
* Sessions end when the manager stops or the computer restarts.
|
|
163
|
-
* Gemini CLI usage meters
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
* Gemini CLI usage meters read its sign-in where Gemini CLI keeps it:
|
|
175
|
+
`oauth_creds.json`, or with `GEMINI_FORCE_ENCRYPTED_FILE_STORAGE=true`
|
|
176
|
+
the OS keychain (macOS, or Linux with `secret-tool`) or its encrypted
|
|
177
|
+
credentials file. A sign-in kept in the Windows Credential Manager
|
|
178
|
+
cannot be read.
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"package": "@anthropic-ai/claude-code",
|
|
9
9
|
"versionArgs": ["--version"],
|
|
10
10
|
"usage": "claude",
|
|
11
|
+
"homeVar": "CLAUDE_CONFIG_DIR",
|
|
12
|
+
"accountEnv": { "CLAUDE_SECURESTORAGE_CONFIG_DIR": "{dir}" },
|
|
13
|
+
"hooks": { "path": "settings.json", "example": "claude-code-settings.json" },
|
|
11
14
|
"modelPattern": "claude-(?:opus|sonnet|haiku|fable|\\d)[a-z0-9.-]*|\\b(?:opus|sonnet|haiku|fable)\\s?\\d+(?:\\.\\d+)?",
|
|
12
15
|
"args": [],
|
|
13
16
|
"resumeArgs": ["--resume", "{id}"],
|
|
@@ -41,6 +44,8 @@
|
|
|
41
44
|
"package": "@openai/codex",
|
|
42
45
|
"versionArgs": ["--version"],
|
|
43
46
|
"usage": "codex",
|
|
47
|
+
"homeVar": "CODEX_HOME",
|
|
48
|
+
"hooks": { "path": "hooks.json", "example": "codex-hooks.json" },
|
|
44
49
|
"modelPattern": "\\bgpt-\\d[a-z0-9.-]*",
|
|
45
50
|
"args": [],
|
|
46
51
|
"resumeArgs": ["resume", "{id}"],
|
|
@@ -72,6 +77,9 @@
|
|
|
72
77
|
"package": "@google/gemini-cli",
|
|
73
78
|
"versionArgs": ["--version"],
|
|
74
79
|
"usage": "gemini",
|
|
80
|
+
"homeVar": "GEMINI_CLI_HOME",
|
|
81
|
+
"accountEnv": { "GEMINI_FORCE_FILE_STORAGE": "true" },
|
|
82
|
+
"hooks": { "path": ".gemini/settings.json", "example": "gemini-settings.json" },
|
|
75
83
|
"modelPattern": "\\bgemini-\\d[a-z0-9.-]*",
|
|
76
84
|
"args": [],
|
|
77
85
|
"resumeArgs": ["--resume", "{id}"],
|
|
@@ -92,6 +100,8 @@
|
|
|
92
100
|
"command": "grok",
|
|
93
101
|
"package": "@xai-official/grok",
|
|
94
102
|
"versionArgs": ["--version"],
|
|
103
|
+
"homeVar": "GROK_HOME",
|
|
104
|
+
"hooks": { "path": "hooks/agent-guild.json", "example": "grok-hooks.json" },
|
|
95
105
|
"modelPattern": "\\bgrok-(?:build|\\d)[a-z0-9.-]*",
|
|
96
106
|
"args": [],
|
|
97
107
|
"resumeArgs": ["--resume", "{id}"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/manager/config.mjs
CHANGED
|
@@ -8,10 +8,15 @@ import fs from 'node:fs';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
11
12
|
|
|
12
13
|
export const DEFAULT_PORT = 47821;
|
|
13
14
|
export const DEFAULT_HOST = '127.0.0.1';
|
|
14
|
-
|
|
15
|
+
/** The package.json the manager runs from; an upgrade replaces it in place. */
|
|
16
|
+
export const PACKAGE_FILE = fileURLToPath(new URL('../../package.json', import.meta.url));
|
|
17
|
+
const packageJson = JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf8'));
|
|
18
|
+
export const VERSION = packageJson.version;
|
|
19
|
+
export const PACKAGE_NAME = packageJson.name;
|
|
15
20
|
|
|
16
21
|
/** Per-user data directory. Override with AGENT_GUILD_HOME (used by tests). */
|
|
17
22
|
export function dataDir() {
|
|
@@ -37,6 +42,7 @@ export const paths = {
|
|
|
37
42
|
get token() { return path.join(dataDir(), 'auth-token'); },
|
|
38
43
|
get runtime() { return path.join(dataDir(), 'manager.json'); },
|
|
39
44
|
get providers() { return path.join(dataDir(), 'providers.json'); },
|
|
45
|
+
get accounts() { return path.join(dataDir(), 'accounts'); },
|
|
40
46
|
get log() { return path.join(dataDir(), 'manager.log'); },
|
|
41
47
|
/** Launchers for agent-guild-report, put first on every session's PATH. */
|
|
42
48
|
get shims() { return path.join(dataDir(), 'bin'); },
|
package/src/manager/main.mjs
CHANGED
|
@@ -9,10 +9,13 @@ import { SessionManager } from './session-manager.mjs';
|
|
|
9
9
|
import { UsageMonitor } from './usage.mjs';
|
|
10
10
|
import { ModelStats } from './model-stats.mjs';
|
|
11
11
|
import { createManagerServer } from './server.mjs';
|
|
12
|
+
import { SelfUpdate } from './self-update.mjs';
|
|
12
13
|
import { resolveBaseEnv, pathReader } from './shell-env.mjs';
|
|
13
14
|
import { writeReportShims } from './report-shims.mjs';
|
|
14
15
|
import {
|
|
15
16
|
DEFAULT_HOST,
|
|
17
|
+
PACKAGE_FILE,
|
|
18
|
+
PACKAGE_NAME,
|
|
16
19
|
VERSION,
|
|
17
20
|
ensureDataDir,
|
|
18
21
|
loadOrCreateToken,
|
|
@@ -27,7 +30,8 @@ const rootDir = path.resolve(here, '../..');
|
|
|
27
30
|
|
|
28
31
|
const VERSION_REFRESH_MS = 60 * 60 * 1000;
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
/** `version` and `packageFile` stand in for the real ones in tests. */
|
|
34
|
+
export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, sessionDefaults, version = VERSION, packageFile = PACKAGE_FILE } = {}) {
|
|
31
35
|
ensureDataDir();
|
|
32
36
|
const token = loadOrCreateToken();
|
|
33
37
|
const baseEnv = resolveBaseEnv();
|
|
@@ -51,12 +55,17 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
let api;
|
|
54
|
-
const
|
|
58
|
+
const selfUpdate = new SelfUpdate({ pkg: PACKAGE_NAME, version, packageFile, registry });
|
|
59
|
+
const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir, selfUpdate });
|
|
55
60
|
const usage = new UsageMonitor({ registry, env: baseEnv });
|
|
56
61
|
const modelStats = new ModelStats({ registry });
|
|
57
62
|
let closing = null;
|
|
58
63
|
|
|
59
|
-
const
|
|
64
|
+
const refreshVersions = () => {
|
|
65
|
+
registry.refreshVersions().catch(() => {});
|
|
66
|
+
selfUpdate.refresh().catch(() => {});
|
|
67
|
+
};
|
|
68
|
+
const versionTimer = setInterval(refreshVersions, VERSION_REFRESH_MS);
|
|
60
69
|
versionTimer.unref();
|
|
61
70
|
|
|
62
71
|
const shutdown = (reason = 'shutdown') => {
|
|
@@ -86,7 +95,8 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
86
95
|
host,
|
|
87
96
|
port,
|
|
88
97
|
webDir,
|
|
89
|
-
version
|
|
98
|
+
version,
|
|
99
|
+
selfUpdate,
|
|
90
100
|
extraOrigins,
|
|
91
101
|
onShutdownRequest: () => shutdown('requested via API').then(() => process.exit(0)),
|
|
92
102
|
});
|
|
@@ -106,11 +116,11 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
|
|
|
106
116
|
host,
|
|
107
117
|
port: api.port,
|
|
108
118
|
url: api.url,
|
|
109
|
-
version
|
|
119
|
+
version,
|
|
110
120
|
startedAt: new Date().toISOString(),
|
|
111
121
|
});
|
|
112
|
-
console.log(`[manager] Agent Guild ${
|
|
113
|
-
|
|
122
|
+
console.log(`[manager] Agent Guild ${version} listening on ${api.url} (pid ${process.pid})`);
|
|
123
|
+
refreshVersions();
|
|
114
124
|
return { api, manager, registry, token, shutdown };
|
|
115
125
|
}
|
|
116
126
|
|
|
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
13
13
|
import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
|
|
14
|
-
import { CHANNEL_LABELS, classifyInstall, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
|
|
14
|
+
import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
|
|
15
15
|
import { weavePaths } from './shell-env.mjs';
|
|
16
|
+
import { paths } from './config.mjs';
|
|
16
17
|
|
|
17
18
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
|
|
@@ -86,6 +87,50 @@ function normalizeChannels(raw) {
|
|
|
86
87
|
return out;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
function normalizeHooks(raw) {
|
|
91
|
+
if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || typeof raw.example !== 'string') return null;
|
|
92
|
+
const parts = raw.path.split(/[\\/]+/);
|
|
93
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..') || !/^[A-Za-z0-9._-]+$/.test(raw.example)) return null;
|
|
94
|
+
return { path: parts.join('/'), example: raw.example };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_ACCOUNT = 'default';
|
|
98
|
+
|
|
99
|
+
function normalizeAccounts(raw, id, homeVar, warnings) {
|
|
100
|
+
const accounts = [{ id: DEFAULT_ACCOUNT, label: 'Default', dir: null }];
|
|
101
|
+
if (raw === undefined || raw === null) return accounts;
|
|
102
|
+
if (!Array.isArray(raw)) {
|
|
103
|
+
warnings.push(`provider "${id}": ignored accounts; it must be an array`);
|
|
104
|
+
return accounts;
|
|
105
|
+
}
|
|
106
|
+
if (!homeVar && raw.length > 0) {
|
|
107
|
+
warnings.push(`provider "${id}": ignored accounts; the provider has no homeVar`);
|
|
108
|
+
return accounts;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of raw) {
|
|
111
|
+
const item = typeof entry === 'string' ? { id: entry } : entry;
|
|
112
|
+
const accountId = item && typeof item === 'object' ? String(item.id ?? '') : '';
|
|
113
|
+
if (!ID_RE.test(accountId)) {
|
|
114
|
+
warnings.push(`provider "${id}": skipped account with invalid id ${JSON.stringify(item?.id ?? entry)}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 40) : accountId.charAt(0).toUpperCase() + accountId.slice(1);
|
|
118
|
+
const dir = typeof item.dir === 'string' && item.dir.trim() ? item.dir.trim() : null;
|
|
119
|
+
const existing = accounts.find((a) => a.id === accountId);
|
|
120
|
+
if (existing) {
|
|
121
|
+
if (accountId !== DEFAULT_ACCOUNT) {
|
|
122
|
+
warnings.push(`provider "${id}": skipped duplicate account "${accountId}"`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
existing.label = label;
|
|
126
|
+
if (dir) warnings.push(`provider "${id}": ignored dir of the default account; it uses the tool's own home folder`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
accounts.push({ id: accountId, label, dir });
|
|
130
|
+
}
|
|
131
|
+
return accounts;
|
|
132
|
+
}
|
|
133
|
+
|
|
89
134
|
function httpsUrl(value, field, id, warnings) {
|
|
90
135
|
if (value === undefined || value === null || value === '') return null;
|
|
91
136
|
try {
|
|
@@ -99,6 +144,7 @@ function httpsUrl(value, field, id, warnings) {
|
|
|
99
144
|
function normalize(raw, platform, warnings) {
|
|
100
145
|
const merged = { ...raw, ...(raw[platform] || {}) };
|
|
101
146
|
for (const key of PLATFORM_KEYS) delete merged[key];
|
|
147
|
+
const homeVar = typeof merged.homeVar === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(merged.homeVar) ? merged.homeVar : null;
|
|
102
148
|
return {
|
|
103
149
|
id: merged.id,
|
|
104
150
|
vendor: String(merged.vendor || merged.id),
|
|
@@ -111,6 +157,10 @@ function normalize(raw, platform, warnings) {
|
|
|
111
157
|
args: Array.isArray(merged.args) ? merged.args.map(String) : [],
|
|
112
158
|
resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
|
|
113
159
|
env: normalizeEnv(merged.env),
|
|
160
|
+
homeVar,
|
|
161
|
+
accountEnv: normalizeEnv(merged.accountEnv),
|
|
162
|
+
hooks: normalizeHooks(merged.hooks),
|
|
163
|
+
accounts: normalizeAccounts(merged.accounts, merged.id, homeVar, warnings),
|
|
114
164
|
color: String(merged.color || '#64748B'),
|
|
115
165
|
monogram: String(merged.monogram || String(merged.vendor || merged.id).charAt(0)).slice(0, 2),
|
|
116
166
|
icon: merged.icon ? String(merged.icon) : null,
|
|
@@ -172,10 +222,12 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
172
222
|
* @param {string} [opts.registryUrl] npm registry for lookups and installs; default: npm's own configuration
|
|
173
223
|
* @param {boolean} [opts.checkUpdates] false skips registry lookups entirely
|
|
174
224
|
* @param {Function} [opts.fetchImpl]
|
|
225
|
+
* @param {string} [opts.accountsDir] where accounts without a dir get their home folders
|
|
175
226
|
*/
|
|
176
|
-
constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null } = {}) {
|
|
227
|
+
constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts } = {}) {
|
|
177
228
|
super();
|
|
178
229
|
this.userFile = userFile;
|
|
230
|
+
this.accountsDir = accountsDir;
|
|
179
231
|
this.env = env || process.env;
|
|
180
232
|
this.platform = platform;
|
|
181
233
|
this.iconDir = iconDir;
|
|
@@ -184,6 +236,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
184
236
|
this.fetchImpl = fetchImpl;
|
|
185
237
|
this.pathReader = pathReader;
|
|
186
238
|
this._pathReadAt = 0;
|
|
239
|
+
this._pathPending = null;
|
|
187
240
|
this._installs = new Map();
|
|
188
241
|
this.versions = new Map();
|
|
189
242
|
this._refreshing = null;
|
|
@@ -209,6 +262,8 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
209
262
|
npmRegistryUrl() {
|
|
210
263
|
if (this.registryUrl) return Promise.resolve(this.registryUrl);
|
|
211
264
|
this._npmRegistry ??= (async () => {
|
|
265
|
+
// PATH discovery in flight may change which npm is found; wait for it.
|
|
266
|
+
await this._pathPending;
|
|
212
267
|
const npm = this.resolveNpm();
|
|
213
268
|
if (!npm) return DEFAULT_NPM_REGISTRY;
|
|
214
269
|
try {
|
|
@@ -236,11 +291,18 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
236
291
|
return pending;
|
|
237
292
|
}
|
|
238
293
|
|
|
239
|
-
|
|
240
|
-
|
|
294
|
+
/** Resolves to true when the PATH changed. A concurrent caller waits for the read already in flight. */
|
|
295
|
+
refreshPath({ force = false } = {}) {
|
|
296
|
+
if (!this.pathReader) return Promise.resolve(false);
|
|
241
297
|
const now = Date.now();
|
|
242
|
-
if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return false;
|
|
298
|
+
if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return this._pathPending ?? Promise.resolve(false);
|
|
243
299
|
this._pathReadAt = now;
|
|
300
|
+
const pending = this._readPath().finally(() => { if (this._pathPending === pending) this._pathPending = null; });
|
|
301
|
+
this._pathPending = pending;
|
|
302
|
+
return pending;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async _readPath() {
|
|
244
306
|
const discovered = await Promise.resolve().then(() => this.pathReader()).catch(() => null);
|
|
245
307
|
if (!discovered) return false;
|
|
246
308
|
const key = pathKey(this.env, this.platform);
|
|
@@ -462,6 +524,27 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
462
524
|
return this.providers.find((p) => p.id === id) || null;
|
|
463
525
|
}
|
|
464
526
|
|
|
527
|
+
accountFor(provider, account) {
|
|
528
|
+
if (account.id === DEFAULT_ACCOUNT || !provider.homeVar) return { id: account.id, label: account.label, dir: null, env: {} };
|
|
529
|
+
const p = this.platform === 'win32' ? path.win32 : path.posix;
|
|
530
|
+
const own = account.dir ? expandHome(account.dir, this.env, this.platform) : null;
|
|
531
|
+
const dir = own && p.isAbsolute(own) ? p.normalize(own) : p.join(this.accountsDir, provider.id, own || account.id);
|
|
532
|
+
const env = { [provider.homeVar]: dir };
|
|
533
|
+
for (const [key, value] of Object.entries(provider.accountEnv)) env[key] = value.replaceAll('{dir}', dir);
|
|
534
|
+
return { id: account.id, label: account.label, dir, env };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
accountsFor(provider) {
|
|
538
|
+
return provider.accounts.map((account) => this.accountFor(provider, account));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
account(provider, id = null) {
|
|
542
|
+
const wanted = id === null || id === undefined || id === '' ? DEFAULT_ACCOUNT : String(id);
|
|
543
|
+
const found = provider.accounts.find((a) => a.id === wanted);
|
|
544
|
+
if (!found) throw refusal(404, 'unknown_account', `${provider.tool} has no account "${wanted}"`);
|
|
545
|
+
return this.accountFor(provider, found);
|
|
546
|
+
}
|
|
547
|
+
|
|
465
548
|
commandFor(provider) {
|
|
466
549
|
return provider.command === '@shell' ? defaultShell(this.env, this.platform) : provider.command;
|
|
467
550
|
}
|
|
@@ -529,6 +612,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
529
612
|
warnings: this.installWarnings(provider, installs),
|
|
530
613
|
npmNote: provider.npmNote,
|
|
531
614
|
usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
|
|
615
|
+
accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
|
|
532
616
|
modelPattern: provider.modelPattern,
|
|
533
617
|
color: provider.color,
|
|
534
618
|
monogram: provider.monogram,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The manager's own version against the npm registry, and the npm session
|
|
2
|
+
// that upgrades it. The running process keeps its code after an upgrade:
|
|
3
|
+
// the new version is used once the manager is restarted.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { compareVersions, fetchManifest } from './versions.mjs';
|
|
8
|
+
import { formatCommand } from './install-channels.mjs';
|
|
9
|
+
import { buildSpawnSpec } from './command-resolver.mjs';
|
|
10
|
+
|
|
11
|
+
const CHECK_TTL_MS = 60 * 60 * 1000;
|
|
12
|
+
const FAILED_CHECK_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Shown as the provider of the upgrade session. */
|
|
15
|
+
export const SELF_PROVIDER = Object.freeze({
|
|
16
|
+
id: 'agent-guild',
|
|
17
|
+
vendor: 'Agent Guild',
|
|
18
|
+
tool: 'Agent Guild',
|
|
19
|
+
color: '#5B5BD6',
|
|
20
|
+
monogram: 'AG',
|
|
21
|
+
iconUrl: null,
|
|
22
|
+
modelPattern: null,
|
|
23
|
+
env: {},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** True for a build that is not a published release, such as a git checkout. */
|
|
27
|
+
export function isDevelopmentBuild(version) {
|
|
28
|
+
return !version || /^0\.0\.0(?:-|$)/.test(String(version));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refusal(status, code, message) {
|
|
32
|
+
return Object.assign(new Error(message), { status, code });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SelfUpdate extends EventEmitter {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} opts.pkg the manager's npm package name
|
|
39
|
+
* @param {string} opts.version the running version
|
|
40
|
+
* @param {string|null} [opts.packageFile] the package.json the manager runs from; read again after an install
|
|
41
|
+
* @param {import('./providers.mjs').ProviderRegistry} opts.registry for the registry URL, npm, and fetch
|
|
42
|
+
*/
|
|
43
|
+
constructor({ pkg, version, packageFile = null, registry }) {
|
|
44
|
+
super();
|
|
45
|
+
this.pkg = pkg;
|
|
46
|
+
this.version = version;
|
|
47
|
+
this.packageFile = packageFile;
|
|
48
|
+
this.registry = registry;
|
|
49
|
+
this.latest = null;
|
|
50
|
+
this.error = null;
|
|
51
|
+
this.checkedAt = 0;
|
|
52
|
+
this.lastInstall = null;
|
|
53
|
+
/** True from the start of an upgrade session until its npm process has exited. */
|
|
54
|
+
this.installing = false;
|
|
55
|
+
/** The on-disk version a failed upgrade left behind, not to be trusted until an upgrade completes. */
|
|
56
|
+
this.suspectVersion = null;
|
|
57
|
+
this._refreshing = null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ask the registry for the latest release, hourly unless `force`. Emits "updated" on a change. */
|
|
61
|
+
refresh({ force = false } = {}) {
|
|
62
|
+
const run = () => this._refresh(force);
|
|
63
|
+
const pending = this._refreshing ? this._refreshing.then(run, run) : run();
|
|
64
|
+
this._refreshing = pending;
|
|
65
|
+
pending.finally(() => { if (this._refreshing === pending) this._refreshing = null; }).catch(() => {});
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _refresh(force) {
|
|
70
|
+
if (!this.registry.checkUpdates || isDevelopmentBuild(this.version)) return;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const ttl = this.error ? FAILED_CHECK_TTL_MS : CHECK_TTL_MS;
|
|
73
|
+
if (!force && now - this.checkedAt < ttl) return;
|
|
74
|
+
this.checkedAt = now;
|
|
75
|
+
const registryUrl = await this.registry.npmRegistryUrl();
|
|
76
|
+
const { manifest, error } = await fetchManifest(this.pkg, 'latest', { registryUrl, fetchImpl: this.registry.fetchImpl });
|
|
77
|
+
// A failed check keeps the release already known; it is still published.
|
|
78
|
+
const latest = manifest?.version ?? this.latest;
|
|
79
|
+
const changed = latest !== this.latest || error !== this.error;
|
|
80
|
+
this.latest = latest;
|
|
81
|
+
this.error = error;
|
|
82
|
+
if (changed) this.emit('updated');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Forget an upgrade's outcome once a newer release or a different install supersedes it. */
|
|
86
|
+
_pruneLastInstall(installed) {
|
|
87
|
+
const last = this.lastInstall;
|
|
88
|
+
if (last && (last.version !== this.latest || last.installedVersion !== installed)) this.lastInstall = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The version of the package files on disk, or null when unreadable (for example mid-install). */
|
|
92
|
+
installedVersion() {
|
|
93
|
+
if (!this.packageFile) return null;
|
|
94
|
+
try {
|
|
95
|
+
const version = JSON.parse(fs.readFileSync(this.packageFile, 'utf8')).version;
|
|
96
|
+
return typeof version === 'string' ? version : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* True while the files on disk are what an upgrade that did not finish
|
|
104
|
+
* left behind: npm may have replaced package.json before it was stopped.
|
|
105
|
+
* Kept apart from `lastInstall`, which a newer release supersedes; the
|
|
106
|
+
* files stay suspect until an upgrade completes or they change again.
|
|
107
|
+
*/
|
|
108
|
+
_diskSuspect(installed) {
|
|
109
|
+
return this.suspectVersion !== null && installed === this.suspectVersion;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A version installed on disk that the running manager does not use yet, or null. */
|
|
113
|
+
pendingVersion(installed = this.installedVersion()) {
|
|
114
|
+
if (this._diskSuspect(installed)) return null;
|
|
115
|
+
return installed && installed !== this.version && compareVersions(installed, this.version) > 0 ? installed : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when the latest release is newer than the running manager and not yet, or not reliably, on disk. */
|
|
119
|
+
available(installed = this.installedVersion()) {
|
|
120
|
+
if (!this.latest || compareVersions(this.latest, this.version) <= 0) return false;
|
|
121
|
+
return this._diskSuspect(installed) || compareVersions(this.latest, installed || this.version) > 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
args() {
|
|
125
|
+
return this.registry.npmArgs({ args: ['install', '-g'], package: this.pkg }, this.latest);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The npm command that performs the upgrade, or null without npm on PATH or a known release. */
|
|
129
|
+
command() {
|
|
130
|
+
const npm = this.registry.resolveNpm();
|
|
131
|
+
return npm && this.latest ? formatCommand(npm, this.args()) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Public description, sent in `/info`, `hello` and `manager.upgrade`. */
|
|
135
|
+
describe() {
|
|
136
|
+
// While npm runs, the files on disk are in flux: package.json may already
|
|
137
|
+
// be new while dependencies are still being written, so nothing is
|
|
138
|
+
// offered or announced until the process has exited.
|
|
139
|
+
if (this.installing) {
|
|
140
|
+
return {
|
|
141
|
+
version: this.version, latestVersion: this.latest, available: false, command: null, guidance: null,
|
|
142
|
+
pendingVersion: null, installing: true, lastInstall: null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const installed = this.installedVersion();
|
|
146
|
+
this._pruneLastInstall(installed);
|
|
147
|
+
const available = this.available(installed);
|
|
148
|
+
const command = available ? this.command() : null;
|
|
149
|
+
return {
|
|
150
|
+
version: this.version,
|
|
151
|
+
latestVersion: this.latest,
|
|
152
|
+
available,
|
|
153
|
+
command,
|
|
154
|
+
guidance: available && !command ? `npm was not found on PATH. Install Node.js from https://nodejs.org, then run: npm install -g ${this.pkg}@${this.latest}` : null,
|
|
155
|
+
pendingVersion: this.pendingVersion(installed),
|
|
156
|
+
installing: false,
|
|
157
|
+
lastInstall: this.lastInstall,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Spawn spec for the upgrade session and the version it installs, or throws with a user-facing message. */
|
|
162
|
+
async spec() {
|
|
163
|
+
if (this.installing) throw refusal(409, 'upgrade_in_progress', 'Agent Guild is already being upgraded');
|
|
164
|
+
if (isDevelopmentBuild(this.version)) {
|
|
165
|
+
throw refusal(400, 'not_updatable', `This is a development build of Agent Guild (${this.version}); it is not upgraded from the registry.`);
|
|
166
|
+
}
|
|
167
|
+
if (!this.registry.checkUpdates) {
|
|
168
|
+
throw refusal(400, 'not_updatable', 'Version checks are off (AGENT_GUILD_NO_UPDATE_CHECK), so Agent Guild cannot upgrade itself.');
|
|
169
|
+
}
|
|
170
|
+
await this.refresh();
|
|
171
|
+
const installed = this.installedVersion();
|
|
172
|
+
this._pruneLastInstall(installed);
|
|
173
|
+
if (!this.available(installed)) {
|
|
174
|
+
throw refusal(400, 'not_updatable', this.latest
|
|
175
|
+
? `Agent Guild ${this.latest} is the latest release${this.pendingVersion(installed) ? ' and is installed; restart the manager to use it' : ''}.`
|
|
176
|
+
: `Could not read the latest Agent Guild release${this.error ? `: ${this.error}` : ''}. Nothing was changed.`);
|
|
177
|
+
}
|
|
178
|
+
const npm = this.registry.resolveNpm();
|
|
179
|
+
if (!npm) {
|
|
180
|
+
throw refusal(409, 'npm_unavailable', 'npm was not found on PATH. Install Node.js from https://nodejs.org and restart the session manager.');
|
|
181
|
+
}
|
|
182
|
+
// buildSpawnSpec wraps npm.cmd in cmd.exe on Windows, which a PTY needs.
|
|
183
|
+
return { spec: buildSpawnSpec(npm, this.args(), this.registry.env, this.registry.platform), version: this.latest };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** An upgrade session started; hold the lock until finishInstall. Emits "updated". */
|
|
187
|
+
beginInstall() {
|
|
188
|
+
this.installing = true;
|
|
189
|
+
this.emit('updated');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Record how the upgrade session ended, once its process has exited. The files on disk say whether the running copy was replaced. */
|
|
193
|
+
finishInstall({ exitCode = null, version = this.latest } = {}) {
|
|
194
|
+
this.installing = false;
|
|
195
|
+
const installed = this.installedVersion();
|
|
196
|
+
let outcome;
|
|
197
|
+
if (exitCode !== 0) outcome = 'failed';
|
|
198
|
+
else if (installed && version && compareVersions(installed, version) >= 0) outcome = 'installed';
|
|
199
|
+
else outcome = 'unchanged';
|
|
200
|
+
if (outcome === 'failed') this.suspectVersion = installed;
|
|
201
|
+
else if (outcome === 'installed') this.suspectVersion = null;
|
|
202
|
+
this.lastInstall = { outcome, exitCode, version, installedVersion: installed, at: Date.now() };
|
|
203
|
+
this.emit('updated');
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/manager/server.mjs
CHANGED
|
@@ -106,9 +106,11 @@ export function createManagerServer({
|
|
|
106
106
|
port = 0,
|
|
107
107
|
webDir,
|
|
108
108
|
version = '0.0.0',
|
|
109
|
+
selfUpdate = null,
|
|
109
110
|
extraOrigins = [],
|
|
110
111
|
onShutdownRequest = () => {},
|
|
111
112
|
}) {
|
|
113
|
+
const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
|
|
112
114
|
const startedAt = new Date().toISOString();
|
|
113
115
|
const vendor = vendorFiles();
|
|
114
116
|
let boundPort = port;
|
|
@@ -198,6 +200,7 @@ export function createManagerServer({
|
|
|
198
200
|
requireAuth(req, url);
|
|
199
201
|
|
|
200
202
|
if (route === '/info' && method === 'GET') {
|
|
203
|
+
selfUpdate?.refresh().catch(() => {});
|
|
201
204
|
return sendJson(res, 200, {
|
|
202
205
|
name: 'agent-guild',
|
|
203
206
|
version,
|
|
@@ -205,8 +208,13 @@ export function createManagerServer({
|
|
|
205
208
|
platform: process.platform,
|
|
206
209
|
startedAt,
|
|
207
210
|
warnings: registry.warnings,
|
|
211
|
+
upgrade: upgradeInfo(),
|
|
208
212
|
});
|
|
209
213
|
}
|
|
214
|
+
if (route === '/upgrade' && method === 'POST') {
|
|
215
|
+
const session = await manager.upgrade();
|
|
216
|
+
return sendJson(res, 201, { session: session.toJSON() });
|
|
217
|
+
}
|
|
210
218
|
if (route === '/providers' && method === 'GET') {
|
|
211
219
|
registry.refreshVersions().catch(() => {});
|
|
212
220
|
return sendJson(res, 200, { providers: registry.list() });
|
|
@@ -333,10 +341,11 @@ export function createManagerServer({
|
|
|
333
341
|
}
|
|
334
342
|
manager.on('event', broadcast);
|
|
335
343
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
344
|
+
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
336
345
|
|
|
337
346
|
function handleEvents(ws) {
|
|
338
347
|
eventClients.add(ws);
|
|
339
|
-
safeSend(ws, { type: 'hello', version, sessions: manager.list() });
|
|
348
|
+
safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
340
349
|
ws.on('close', () => eventClients.delete(ws));
|
|
341
350
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
342
351
|
}
|