@oddessentials/agent-guild 0.2.0 → 0.4.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 +20 -7
- package/src/manager/news.mjs +470 -0
- package/src/manager/providers.mjs +89 -5
- package/src/manager/self-update.mjs +205 -0
- package/src/manager/server.mjs +15 -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 +489 -12
- package/web/art/art.css +28 -6
- package/web/index.html +31 -0
- package/web/styles.css +87 -19
- package/web/theme.js +10 -0
|
@@ -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
|
@@ -101,14 +101,17 @@ export function createManagerServer({
|
|
|
101
101
|
registry,
|
|
102
102
|
usage,
|
|
103
103
|
modelStats,
|
|
104
|
+
news = null,
|
|
104
105
|
token,
|
|
105
106
|
host = '127.0.0.1',
|
|
106
107
|
port = 0,
|
|
107
108
|
webDir,
|
|
108
109
|
version = '0.0.0',
|
|
110
|
+
selfUpdate = null,
|
|
109
111
|
extraOrigins = [],
|
|
110
112
|
onShutdownRequest = () => {},
|
|
111
113
|
}) {
|
|
114
|
+
const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
|
|
112
115
|
const startedAt = new Date().toISOString();
|
|
113
116
|
const vendor = vendorFiles();
|
|
114
117
|
let boundPort = port;
|
|
@@ -198,6 +201,7 @@ export function createManagerServer({
|
|
|
198
201
|
requireAuth(req, url);
|
|
199
202
|
|
|
200
203
|
if (route === '/info' && method === 'GET') {
|
|
204
|
+
selfUpdate?.refresh().catch(() => {});
|
|
201
205
|
return sendJson(res, 200, {
|
|
202
206
|
name: 'agent-guild',
|
|
203
207
|
version,
|
|
@@ -205,8 +209,13 @@ export function createManagerServer({
|
|
|
205
209
|
platform: process.platform,
|
|
206
210
|
startedAt,
|
|
207
211
|
warnings: registry.warnings,
|
|
212
|
+
upgrade: upgradeInfo(),
|
|
208
213
|
});
|
|
209
214
|
}
|
|
215
|
+
if (route === '/upgrade' && method === 'POST') {
|
|
216
|
+
const session = await manager.upgrade();
|
|
217
|
+
return sendJson(res, 201, { session: session.toJSON() });
|
|
218
|
+
}
|
|
210
219
|
if (route === '/providers' && method === 'GET') {
|
|
211
220
|
registry.refreshVersions().catch(() => {});
|
|
212
221
|
return sendJson(res, 200, { providers: registry.list() });
|
|
@@ -222,6 +231,9 @@ export function createManagerServer({
|
|
|
222
231
|
if (route === '/model-stats' && method === 'GET') {
|
|
223
232
|
return sendJson(res, 200, await modelStats.snapshot(manager.list()));
|
|
224
233
|
}
|
|
234
|
+
if (route === '/news' && method === 'GET' && news) {
|
|
235
|
+
return sendJson(res, 200, news.snapshot());
|
|
236
|
+
}
|
|
225
237
|
const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
|
|
226
238
|
if (installMatch && method === 'POST') {
|
|
227
239
|
const body = await readJsonBody(req);
|
|
@@ -333,10 +345,12 @@ export function createManagerServer({
|
|
|
333
345
|
}
|
|
334
346
|
manager.on('event', broadcast);
|
|
335
347
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
348
|
+
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
349
|
+
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
336
350
|
|
|
337
351
|
function handleEvents(ws) {
|
|
338
352
|
eventClients.add(ws);
|
|
339
|
-
safeSend(ws, { type: 'hello', version, sessions: manager.list() });
|
|
353
|
+
safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
340
354
|
ws.on('close', () => eventClients.delete(ws));
|
|
341
355
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
342
356
|
}
|
|
@@ -6,11 +6,14 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
|
-
import {
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { Session, newId, clampDimension, cleanName } from './session.mjs';
|
|
10
11
|
import { prependPath } from './report-shims.mjs';
|
|
11
12
|
import { CHANNEL_LABELS } from './install-channels.mjs';
|
|
13
|
+
import { SELF_PROVIDER } from './self-update.mjs';
|
|
12
14
|
|
|
13
15
|
export const MAX_SESSIONS = 32;
|
|
16
|
+
const examplesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../examples');
|
|
14
17
|
|
|
15
18
|
function httpError(status, message, code) {
|
|
16
19
|
return Object.assign(new Error(message), { status, code });
|
|
@@ -46,14 +49,16 @@ export class SessionManager extends EventEmitter {
|
|
|
46
49
|
* @param {() => string} opts.getApiUrl base URL handed to tools for reporting
|
|
47
50
|
* @param {object} [opts.sessionDefaults] passed through to Session
|
|
48
51
|
* @param {string|null} [opts.shimDir] folder with the agent-guild-report launchers, put first on PATH
|
|
52
|
+
* @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
|
|
49
53
|
*/
|
|
50
|
-
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null }) {
|
|
54
|
+
constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null }) {
|
|
51
55
|
super();
|
|
52
56
|
this.registry = registry;
|
|
53
57
|
this.baseEnv = baseEnv;
|
|
54
58
|
this.getApiUrl = getApiUrl;
|
|
55
59
|
this.sessionDefaults = sessionDefaults;
|
|
56
60
|
this.shimDir = shimDir;
|
|
61
|
+
this.selfUpdate = selfUpdate;
|
|
57
62
|
this.sessions = new Map();
|
|
58
63
|
/** Removed sessions whose process has not exited yet. */
|
|
59
64
|
this.exiting = new Set();
|
|
@@ -83,21 +88,44 @@ export class SessionManager extends EventEmitter {
|
|
|
83
88
|
return dir;
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
create({ providerId, cwd, cols, rows, name, args, resume } = {}) {
|
|
91
|
+
create({ providerId, cwd, cols, rows, name, args, resume, account } = {}) {
|
|
87
92
|
const provider = this.registry.get(String(providerId || ''));
|
|
88
93
|
if (!provider) throw httpError(404, `unknown provider "${providerId}"`, 'unknown_provider');
|
|
89
94
|
if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) {
|
|
90
95
|
throw httpError(400, 'args must be an array of strings', 'bad_args');
|
|
91
96
|
}
|
|
97
|
+
if (account !== undefined && account !== null && typeof account !== 'string') throw httpError(400, 'account must be a string', 'bad_account');
|
|
92
98
|
const resumeId = cleanResumeId(resume);
|
|
93
99
|
const workDir = this.resolveCwd(cwd);
|
|
100
|
+
const signIn = this.registry.account(provider, account);
|
|
94
101
|
const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId);
|
|
95
|
-
|
|
102
|
+
this.prepareAccount(provider, signIn);
|
|
103
|
+
const sessionName = cleanName(name) || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null);
|
|
104
|
+
const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn });
|
|
96
105
|
const model = modelFromArgs([...provider.args, ...(args || [])]);
|
|
97
106
|
if (model) session.setModel({ name: model }, 'args');
|
|
98
107
|
return session;
|
|
99
108
|
}
|
|
100
109
|
|
|
110
|
+
prepareAccount(provider, account) {
|
|
111
|
+
if (!account.dir) return;
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(account.dir, { recursive: true, mode: 0o700 });
|
|
114
|
+
if (!provider.hooks) return;
|
|
115
|
+
const target = path.join(account.dir, ...provider.hooks.path.split('/'));
|
|
116
|
+
const example = path.join(examplesDir, provider.hooks.example);
|
|
117
|
+
if (fs.existsSync(target)) return;
|
|
118
|
+
if (!fs.existsSync(example)) {
|
|
119
|
+
console.warn(`[accounts] no hooks example ${example} for ${provider.tool}; ${target} was not written`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
123
|
+
fs.copyFileSync(example, target, fs.constants.COPYFILE_EXCL);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw httpError(500, `could not prepare the ${account.label} account folder ${account.dir}: ${err.message}`, 'account_unavailable');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/**
|
|
102
130
|
* Refuses while sessions of that provider are running unless `force` is
|
|
103
131
|
* set, because replacing a tool under a running process can break it.
|
|
@@ -140,6 +168,29 @@ export class SessionManager extends EventEmitter {
|
|
|
140
168
|
return n;
|
|
141
169
|
}
|
|
142
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Upgrade the manager itself: a visible session running npm. Sessions
|
|
173
|
+
* keep running; the new version is used once the manager is restarted.
|
|
174
|
+
*/
|
|
175
|
+
async upgrade() {
|
|
176
|
+
if (!this.selfUpdate) throw httpError(400, 'this manager cannot upgrade itself', 'not_updatable');
|
|
177
|
+
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
178
|
+
const inProgress = () => httpError(409, 'Agent Guild is already being upgraded', 'upgrade_in_progress');
|
|
179
|
+
if (this.selfUpdate.installing) throw inProgress();
|
|
180
|
+
const { spec, version } = await this.selfUpdate.spec();
|
|
181
|
+
if (this.selfUpdate.installing) throw inProgress();
|
|
182
|
+
const session = this._spawn({
|
|
183
|
+
provider: SELF_PROVIDER, description: SELF_PROVIDER, spawnSpec: spec,
|
|
184
|
+
cwd: os.homedir(), name: `Upgrade Agent Guild to ${version}`, task: 'upgrade',
|
|
185
|
+
});
|
|
186
|
+
// The lock is held until the npm process has exited, not until the
|
|
187
|
+
// session is removed: a removed session's process may still be writing
|
|
188
|
+
// the package, and two installers must not touch it at once.
|
|
189
|
+
this.selfUpdate.beginInstall();
|
|
190
|
+
session.exited.then(() => this.selfUpdate.finishInstall({ exitCode: session.exitCode, version }));
|
|
191
|
+
return session;
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
runningFor(providerId) {
|
|
144
195
|
let n = 0;
|
|
145
196
|
for (const s of this.sessions.values()) if (s.status === 'running' && s.task === null && s.provider.id === providerId) n++;
|
|
@@ -153,18 +204,17 @@ export class SessionManager extends EventEmitter {
|
|
|
153
204
|
return n;
|
|
154
205
|
}
|
|
155
206
|
|
|
156
|
-
_spawn({ provider, spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null }) {
|
|
207
|
+
_spawn({ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, account = null }) {
|
|
157
208
|
if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
|
|
158
209
|
if (this.sessions.size >= MAX_SESSIONS) {
|
|
159
210
|
throw httpError(429, `session limit reached (${MAX_SESSIONS}); remove finished sessions first`, 'too_many_sessions');
|
|
160
211
|
}
|
|
161
|
-
const description = this.registry.describe(provider);
|
|
162
212
|
const id = newId();
|
|
163
213
|
const reportToken = crypto.randomBytes(16).toString('hex');
|
|
164
214
|
|
|
165
215
|
// The tool's hooks run `agent-guild-report` by name, so the launchers
|
|
166
216
|
// go first on PATH, after any provider PATH override.
|
|
167
|
-
const env = prependPath(mergeEnv([this.baseEnv, provider.env, {
|
|
217
|
+
const env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
|
|
168
218
|
TERM: 'xterm-256color',
|
|
169
219
|
COLORTERM: 'truecolor',
|
|
170
220
|
AGENT_GUILD_SESSION_ID: id,
|
|
@@ -194,6 +244,7 @@ export class SessionManager extends EventEmitter {
|
|
|
194
244
|
reportToken,
|
|
195
245
|
resume,
|
|
196
246
|
task,
|
|
247
|
+
account: account ? { id: account.id, label: account.label } : null,
|
|
197
248
|
});
|
|
198
249
|
} catch (err) {
|
|
199
250
|
throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
|
package/src/manager/session.mjs
CHANGED
|
@@ -54,7 +54,8 @@ export class Session extends EventEmitter {
|
|
|
54
54
|
* @param {number} opts.rows
|
|
55
55
|
* @param {string} [opts.name]
|
|
56
56
|
* @param {string|null} [opts.resume] id of the tool's own session being resumed
|
|
57
|
-
* @param {string|null} [opts.task] "install" for a package install, else null
|
|
57
|
+
* @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, else null
|
|
58
|
+
* @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
|
|
58
59
|
* @param {string} opts.reportToken
|
|
59
60
|
* @param {number} [opts.scrollback]
|
|
60
61
|
* @param {number} [opts.activityIdleMs]
|
|
@@ -68,6 +69,7 @@ export class Session extends EventEmitter {
|
|
|
68
69
|
this.name = cleanName(opts.name) || opts.provider.tool;
|
|
69
70
|
this.resume = opts.resume ?? null;
|
|
70
71
|
this.task = opts.task ?? null;
|
|
72
|
+
this.account = opts.account ?? null;
|
|
71
73
|
this.cwd = opts.cwd;
|
|
72
74
|
this.cols = opts.cols;
|
|
73
75
|
this.rows = opts.rows;
|
|
@@ -505,6 +507,7 @@ export class Session extends EventEmitter {
|
|
|
505
507
|
cwd: this.cwd,
|
|
506
508
|
resume: this.resume,
|
|
507
509
|
task: this.task,
|
|
510
|
+
account: this.account,
|
|
508
511
|
pid: this.pid,
|
|
509
512
|
status: this.status,
|
|
510
513
|
exitCode: this.exitCode,
|
package/src/manager/usage.mjs
CHANGED
|
@@ -19,6 +19,10 @@ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
|
19
19
|
|
|
20
20
|
export class UsageError extends Error {}
|
|
21
21
|
|
|
22
|
+
function notSignedIn(message) {
|
|
23
|
+
return Object.assign(new UsageError(message), { notSignedIn: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
22
26
|
function shortPath(file) {
|
|
23
27
|
const home = os.homedir();
|
|
24
28
|
return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
@@ -112,7 +116,7 @@ export async function readClaudeCredentials({
|
|
|
112
116
|
try {
|
|
113
117
|
raw = await fs.promises.readFile(file, 'utf8');
|
|
114
118
|
} catch {
|
|
115
|
-
throw
|
|
119
|
+
throw notSignedIn(`Claude Code is not signed in on this machine (no ${keychain ? 'keychain item or ' : ''}${shortPath(file)})`);
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
let creds;
|
|
@@ -195,7 +199,7 @@ export async function readCodexCredentials({ file = codexAuthFile() } = {}) {
|
|
|
195
199
|
try {
|
|
196
200
|
raw = await fs.promises.readFile(file, 'utf8');
|
|
197
201
|
} catch {
|
|
198
|
-
throw
|
|
202
|
+
throw notSignedIn(`Codex CLI is not signed in on this machine (no ${shortPath(file)})`);
|
|
199
203
|
}
|
|
200
204
|
let auth;
|
|
201
205
|
try { auth = JSON.parse(raw); } catch { throw new UsageError('Codex CLI credentials could not be parsed'); }
|
|
@@ -260,11 +264,37 @@ export function geminiCredentialsFile(env = process.env) {
|
|
|
260
264
|
return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini', 'oauth_creds.json');
|
|
261
265
|
}
|
|
262
266
|
|
|
267
|
+
export function geminiKeychainFile(env = process.env) {
|
|
268
|
+
return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini', 'gemini-credentials.json');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function geminiFileKey({ hostname = os.hostname(), username = os.userInfo().username } = {}) {
|
|
272
|
+
return crypto.scryptSync(GEMINI_KEYCHAIN_SERVICE, `${hostname}-${username}-gemini-cli`, 32);
|
|
273
|
+
}
|
|
274
|
+
|
|
263
275
|
/**
|
|
264
|
-
* Gemini CLI keeps its
|
|
265
|
-
*
|
|
266
|
-
*
|
|
276
|
+
* The item Gemini CLI keeps in its own encrypted file when it does not use
|
|
277
|
+
* the OS keychain: AES-256-GCM as iv:tag:ciphertext in hex, over a JSON
|
|
278
|
+
* map of service to account to secret.
|
|
267
279
|
*/
|
|
280
|
+
export async function readGeminiFileKeychain(file, { key = geminiFileKey() } = {}) {
|
|
281
|
+
let raw;
|
|
282
|
+
try {
|
|
283
|
+
raw = await fs.promises.readFile(file, 'utf8');
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const [iv, tag, encrypted] = raw.trim().split(':').map((part) => Buffer.from(part, 'hex'));
|
|
289
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
290
|
+
decipher.setAuthTag(tag);
|
|
291
|
+
const json = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
|
292
|
+
return JSON.parse(json)?.[GEMINI_KEYCHAIN_SERVICE]?.[GEMINI_KEYCHAIN_ACCOUNT] ?? null;
|
|
293
|
+
} catch {
|
|
294
|
+
throw new UsageError(`Gemini CLI credentials file ${shortPath(file)} could not be decrypted`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
268
298
|
export function geminiKeychainLookup(platform) {
|
|
269
299
|
if (platform === 'darwin') return { file: 'security', args: ['find-generic-password', '-s', GEMINI_KEYCHAIN_SERVICE, '-a', GEMINI_KEYCHAIN_ACCOUNT, '-w'] };
|
|
270
300
|
// keytar stores libsecret items with the attributes "service" and "account".
|
|
@@ -272,18 +302,54 @@ export function geminiKeychainLookup(platform) {
|
|
|
272
302
|
return null;
|
|
273
303
|
}
|
|
274
304
|
|
|
275
|
-
|
|
305
|
+
/**
|
|
306
|
+
* What the OS keychain holds for Gemini CLI: the item, or that it is
|
|
307
|
+
* absent, that there is no keychain to ask (so the tool keeps its own
|
|
308
|
+
* encrypted file instead), or that it is one this manager cannot read.
|
|
309
|
+
*/
|
|
310
|
+
export async function readGeminiKeychainItem(platform) {
|
|
276
311
|
const spec = geminiKeychainLookup(platform);
|
|
277
|
-
if (!spec) return
|
|
278
|
-
|
|
312
|
+
if (!spec) return { status: platform === 'win32' ? 'unreadable' : 'unavailable' };
|
|
313
|
+
try {
|
|
314
|
+
const { stdout } = await runSpec(spec, { timeoutMs: 30000 });
|
|
315
|
+
return stdout.trim() ? { status: 'found', item: stdout } : { status: 'absent' };
|
|
316
|
+
} catch (err) {
|
|
317
|
+
// security exits 44 for a missing item; secret-tool exits 1 for one and
|
|
318
|
+
// says why on stderr when it has no Secret Service at all.
|
|
319
|
+
if (platform === 'darwin' && err.code === 44) return { status: 'absent' };
|
|
320
|
+
if (platform === 'linux' && err.code === 1 && !String(err.stderr || '').trim()) return { status: 'absent' };
|
|
321
|
+
return { status: 'unavailable' };
|
|
322
|
+
}
|
|
279
323
|
}
|
|
280
324
|
|
|
325
|
+
/** Gemini CLI's own storage choice, read from the environment the tool runs with. */
|
|
326
|
+
export function geminiStorageMode(env = process.env) {
|
|
327
|
+
return { encrypted: env.GEMINI_FORCE_ENCRYPTED_FILE_STORAGE === 'true', fileStorage: env.GEMINI_FORCE_FILE_STORAGE === 'true' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Reads the sign-in from where Gemini CLI keeps it: oauth_creds.json, or
|
|
332
|
+
* with GEMINI_FORCE_ENCRYPTED_FILE_STORAGE the OS keychain item, or its
|
|
333
|
+
* encrypted file when GEMINI_FORCE_FILE_STORAGE is set or there is no
|
|
334
|
+
* keychain. An absent item falls back to oauth_creds.json, as the tool
|
|
335
|
+
* itself migrates it from there.
|
|
336
|
+
*/
|
|
281
337
|
export async function readGeminiCredentials({
|
|
282
338
|
file = geminiCredentialsFile(),
|
|
339
|
+
keychainFile = geminiKeychainFile(),
|
|
283
340
|
platform = process.platform,
|
|
341
|
+
encrypted = false,
|
|
342
|
+
fileStorage = false,
|
|
284
343
|
readKeychain = readGeminiKeychainItem,
|
|
344
|
+
readFileKeychain = readGeminiFileKeychain,
|
|
285
345
|
} = {}) {
|
|
286
|
-
|
|
346
|
+
let raw = null;
|
|
347
|
+
if (encrypted) {
|
|
348
|
+
const keychain = fileStorage ? { status: 'unavailable' } : await readKeychain(platform);
|
|
349
|
+
if (keychain.status === 'found') raw = keychain.item;
|
|
350
|
+
else if (keychain.status === 'unavailable') raw = await readFileKeychain(keychainFile);
|
|
351
|
+
else if (keychain.status === 'unreadable') throw new UsageError('Gemini CLI keeps its sign-in in the Windows Credential Manager, which cannot be read from here');
|
|
352
|
+
}
|
|
287
353
|
if (raw && raw.trim()) {
|
|
288
354
|
let item;
|
|
289
355
|
try { item = JSON.parse(raw); } catch { throw new UsageError('Gemini CLI credentials could not be parsed'); }
|
|
@@ -295,7 +361,7 @@ export async function readGeminiCredentials({
|
|
|
295
361
|
try {
|
|
296
362
|
legacy = JSON.parse(await fs.promises.readFile(file, 'utf8'));
|
|
297
363
|
} catch {
|
|
298
|
-
throw
|
|
364
|
+
throw notSignedIn(`Gemini CLI is not signed in on this machine (no "${GEMINI_KEYCHAIN_SERVICE}" keychain item or ${shortPath(file)})`);
|
|
299
365
|
}
|
|
300
366
|
if (typeof legacy?.access_token !== 'string' || !legacy.access_token) throw new UsageError('Gemini CLI credentials could not be parsed');
|
|
301
367
|
const client = typeof legacy.client_id === 'string' && typeof legacy.client_secret === 'string'
|
|
@@ -471,28 +537,35 @@ export class UsageMonitor {
|
|
|
471
537
|
this.gemini = new Map();
|
|
472
538
|
}
|
|
473
539
|
|
|
474
|
-
/** Snapshots for every provider that has a usage source. */
|
|
540
|
+
/** Snapshots for every account of every provider that has a usage source. */
|
|
475
541
|
all() {
|
|
476
|
-
|
|
542
|
+
const jobs = [];
|
|
543
|
+
for (const provider of this.registry.providers) {
|
|
544
|
+
if (!provider.usage) continue;
|
|
545
|
+
for (const account of this.registry.accountsFor(provider)) jobs.push(this.snapshot(provider, account));
|
|
546
|
+
}
|
|
547
|
+
return Promise.all(jobs);
|
|
477
548
|
}
|
|
478
549
|
|
|
479
|
-
snapshot(provider) {
|
|
480
|
-
const
|
|
550
|
+
snapshot(provider, account = this.registry.account(provider)) {
|
|
551
|
+
const key = `${provider.id}\0${account.id}`;
|
|
552
|
+
const entry = this.cache.get(key);
|
|
481
553
|
const now = Date.now();
|
|
482
554
|
if (entry?.inflight) return entry.inflight;
|
|
483
555
|
if (entry && now - entry.at < entry.ttl) return Promise.resolve(entry.snapshot);
|
|
484
|
-
const inflight = this._fetch(provider).then((snapshot) => {
|
|
556
|
+
const inflight = this._fetch(provider, account, key).then((snapshot) => {
|
|
485
557
|
const ttl = snapshot.rateLimited ? RATE_LIMITED_TTL_MS : this.ttlMs;
|
|
486
|
-
this.cache.set(
|
|
558
|
+
this.cache.set(key, { snapshot, at: Date.now(), ttl });
|
|
487
559
|
return snapshot;
|
|
488
560
|
});
|
|
489
|
-
this.cache.set(
|
|
561
|
+
this.cache.set(key, { ...entry, inflight });
|
|
490
562
|
return inflight;
|
|
491
563
|
}
|
|
492
564
|
|
|
493
|
-
async _fetch(provider) {
|
|
494
|
-
const base = { providerId: provider.id, plan: null, windows: [], credits: null, fetchedAt: new Date().toISOString(), error: null };
|
|
495
|
-
const env = { ...this.env, ...provider.env };
|
|
565
|
+
async _fetch(provider, account, key) {
|
|
566
|
+
const base = { providerId: provider.id, accountId: account.id, plan: null, windows: [], credits: null, signedIn: null, fetchedAt: new Date().toISOString(), error: null };
|
|
567
|
+
const env = { ...this.env, ...provider.env, ...account.env };
|
|
568
|
+
let signedIn = null;
|
|
496
569
|
try {
|
|
497
570
|
let result;
|
|
498
571
|
if (provider.usage === 'claude') {
|
|
@@ -501,16 +574,24 @@ export class UsageMonitor {
|
|
|
501
574
|
keychain: this.platform === 'darwin',
|
|
502
575
|
service: claudeKeychainService(env),
|
|
503
576
|
});
|
|
577
|
+
signedIn = true;
|
|
504
578
|
result = await fetchClaudeUsage({ ...creds, version: this.registry.versions.get(provider.id)?.installed, fetchImpl: this.fetchImpl });
|
|
505
579
|
} else if (provider.usage === 'codex') {
|
|
506
580
|
const creds = await this.readers.codex({ file: codexAuthFile(env) });
|
|
581
|
+
signedIn = true;
|
|
507
582
|
result = await fetchCodexUsage({ ...creds, fetchImpl: this.fetchImpl });
|
|
508
583
|
} else if (provider.usage === 'gemini') {
|
|
509
|
-
const creds = await this.readers.gemini({
|
|
584
|
+
const creds = await this.readers.gemini({
|
|
585
|
+
file: geminiCredentialsFile(env),
|
|
586
|
+
keychainFile: geminiKeychainFile(env),
|
|
587
|
+
platform: this.platform,
|
|
588
|
+
...geminiStorageMode(env),
|
|
589
|
+
});
|
|
590
|
+
signedIn = true;
|
|
510
591
|
// The refreshed token, project and plan belong to one sign-in; a new
|
|
511
592
|
// sign-in (another account, or the same one again) starts over.
|
|
512
593
|
const signIn = creds.refreshToken || creds.accessToken;
|
|
513
|
-
const cached = this.gemini.get(
|
|
594
|
+
const cached = this.gemini.get(key);
|
|
514
595
|
const known = cached?.signIn === signIn ? cached : {};
|
|
515
596
|
const fresh = known.token && known.token.expiresAt > Date.now() + 60000 ? known.token : null;
|
|
516
597
|
const { plan, windows, project, token } = await fetchGeminiUsage({
|
|
@@ -521,15 +602,15 @@ export class UsageMonitor {
|
|
|
521
602
|
version: this.registry.versions.get(provider.id)?.installed,
|
|
522
603
|
fetchImpl: this.fetchImpl,
|
|
523
604
|
});
|
|
524
|
-
this.gemini.set(
|
|
605
|
+
this.gemini.set(key, { signIn, project, token, plan: plan ?? known.plan ?? null });
|
|
525
606
|
result = { plan: plan ?? known.plan ?? null, windows };
|
|
526
607
|
} else {
|
|
527
608
|
result = await commandUsage(provider.usage, env, this.platform);
|
|
528
609
|
}
|
|
529
|
-
return { ...base, ...result };
|
|
610
|
+
return { ...base, signedIn, ...result };
|
|
530
611
|
} catch (err) {
|
|
531
612
|
const message = err instanceof UsageError ? err.message : `usage check failed: ${err.name === 'TimeoutError' ? 'timed out' : err.message}`;
|
|
532
|
-
return { ...base, error: message, ...(err.rateLimited ? { rateLimited: true } : {}) };
|
|
613
|
+
return { ...base, signedIn: err.notSignedIn ? false : signedIn, error: message, ...(err.rateLimited ? { rateLimited: true } : {}) };
|
|
533
614
|
}
|
|
534
615
|
}
|
|
535
616
|
}
|