@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.
@@ -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 { Session, newId, clampDimension } from './session.mjs';
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
- const session = this._spawn({ provider, spawnSpec, cwd: workDir, cols, rows, name, resume: resumeId });
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');
@@ -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,
@@ -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 new UsageError(`Claude Code is not signed in on this machine (no ${keychain ? 'keychain item or ' : ''}${shortPath(file)})`);
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 new UsageError(`Codex CLI is not signed in on this machine (no ${shortPath(file)})`);
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 OAuth token in the OS keychain (service
265
- * "gemini-cli-oauth", account "main-account") and, before it did, in
266
- * oauth_creds.json. Its encrypted-file fallback cannot be read from here.
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
- function readGeminiKeychainItem(platform) {
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 Promise.resolve(null);
278
- return runSpec(spec, { timeoutMs: 30000 }).then((r) => r.stdout, () => null);
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
- const raw = await readKeychain(platform);
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 new UsageError(`Gemini CLI is not signed in on this machine (no "${GEMINI_KEYCHAIN_SERVICE}" keychain item or ${shortPath(file)})`);
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
- return Promise.all(this.registry.providers.filter((p) => p.usage).map((p) => this.snapshot(p)));
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 entry = this.cache.get(provider.id);
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(provider.id, { snapshot, at: Date.now(), ttl });
558
+ this.cache.set(key, { snapshot, at: Date.now(), ttl });
487
559
  return snapshot;
488
560
  });
489
- this.cache.set(provider.id, { ...entry, inflight });
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({ file: geminiCredentialsFile(env), platform: this.platform });
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(provider.id);
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(provider.id, { signIn, project, token, plan: plan ?? known.plan ?? null });
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
  }