@oddessentials/agent-guild 0.10.0 → 0.12.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.
@@ -3,11 +3,11 @@
3
3
  // byte replay), and the sub-agents and model the coding tool has reported.
4
4
 
5
5
  import { EventEmitter } from 'node:events';
6
- import { execFile } from 'node:child_process';
7
6
  import crypto from 'node:crypto';
8
7
  import pty from 'node-pty';
9
8
  import headless from '@xterm/headless';
10
9
  import serializeAddon from '@xterm/addon-serialize';
10
+ import { killWindowsTree } from './command-resolver.mjs';
11
11
 
12
12
  const { Terminal } = headless;
13
13
  const { SerializeAddon } = serializeAddon;
@@ -19,6 +19,10 @@ const MODEL_SOURCE_RANK = { args: 0, screen: 1, report: 2 };
19
19
  const SCREEN_SCAN_DELAY_MS = 400;
20
20
  const SCREEN_SCAN_MAX_DELAY_MS = 2000;
21
21
  const MAX_TOOL_SESSION_ID = 200;
22
+ const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_required', 'unsupported']);
23
+ const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
24
+ const MAX_SHELLS = 256;
25
+ const MAX_ENDED_TASKS = 256;
22
26
 
23
27
  /**
24
28
  * Colours reported to programs that query them (OSC 10/11/12), matching the
@@ -81,6 +85,13 @@ export class Session extends EventEmitter {
81
85
  this.activityIdleMs = opts.activityIdleMs ?? 2500;
82
86
  this.doneAgentLingerMs = opts.doneAgentLingerMs ?? 15000;
83
87
  this.killGraceMs = opts.killGraceMs ?? 4000;
88
+ this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
89
+ this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
90
+ this._reportingTimer = null;
91
+ this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
92
+ this.shells = new Map();
93
+ this._shellSeq = 0;
94
+ this._endedTasks = new Set();
84
95
  this.createdAt = new Date().toISOString();
85
96
  this.exitedAt = null;
86
97
  this.status = 'running';
@@ -190,7 +201,9 @@ export class Session extends EventEmitter {
190
201
  clearTimeout(this._activityTimer);
191
202
  clearTimeout(this._killTimer);
192
203
  clearTimeout(this._scanTimer);
204
+ clearTimeout(this._reportingTimer);
193
205
  this._clearAgents();
206
+ this._clearShells();
194
207
  // Let the headless terminal finish parsing before announcing the exit so
195
208
  // any client attaching afterwards still sees the final screen.
196
209
  this.term.write('', () => {
@@ -260,6 +273,21 @@ export class Session extends EventEmitter {
260
273
  try { this.pty.write(data); } catch { /* process is exiting */ }
261
274
  }
262
275
 
276
+ input(data) {
277
+ this.write(data);
278
+ if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && /[\r\n]/.test(data)) {
279
+ this._reportingTimer = setTimeout(() => {
280
+ if (this.reporting?.state !== 'pending' || this.status !== 'running') return;
281
+ this.reporting = {
282
+ state: 'unavailable',
283
+ reason: `${this.provider.tool} has not run Agent Guild's reporting hooks yet. That is expected while it signs in or sets up; otherwise its hooks may be turned off, restricted by an administrator, or not trusted for this folder.`,
284
+ };
285
+ this._changed();
286
+ }, this.reportingTimeoutMs);
287
+ this._reportingTimer.unref?.();
288
+ }
289
+ }
290
+
263
291
  _reply(data) {
264
292
  this.write(data);
265
293
  }
@@ -302,9 +330,7 @@ export class Session extends EventEmitter {
302
330
  };
303
331
  const pid = this.pty.pid;
304
332
  if (!pid) return fallback();
305
- execFile('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }, (err) => {
306
- if (err) fallback();
307
- });
333
+ killWindowsTree(pid, (err) => { if (err) fallback(); });
308
334
  }
309
335
 
310
336
  /**
@@ -317,6 +343,8 @@ export class Session extends EventEmitter {
317
343
  this.disposed = true;
318
344
  clearTimeout(this._activityTimer);
319
345
  clearTimeout(this._scanTimer);
346
+ clearTimeout(this._reportingTimer);
347
+ this._clearShells();
320
348
  for (const t of this._agentTimers.values()) clearTimeout(t);
321
349
  this.subscribers.clear();
322
350
  this.term.dispose();
@@ -339,12 +367,9 @@ export class Session extends EventEmitter {
339
367
  reportAgent(report, source = 'api') {
340
368
  if (!report || typeof report !== 'object') throw badRequest('agent report must be an object');
341
369
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
370
+ if (source === 'api') this._reportingHeard();
342
371
  if (report.finishForeground === true && report.agentId === undefined) {
343
- // The tool is between turns, so no foreground agent can still be
344
- // running; one that is never got its end event.
345
- for (const agent of [...this.agents.values()]) {
346
- if (agent.foreground && agent.status === 'working') this.reportAgent({ agentId: agent.id, status: 'done' }, source);
347
- }
372
+ this._endForegroundShells(null);
348
373
  return null;
349
374
  }
350
375
  const id = String(report.agentId ?? report.agent ?? report.id ?? '').trim().slice(0, 128);
@@ -360,6 +385,7 @@ export class Session extends EventEmitter {
360
385
  }
361
386
  const now = new Date().toISOString();
362
387
  const existing = this.agents.get(id);
388
+ if (status === 'done') this._endForegroundShells(id);
363
389
  // A first report that already says done would only flash an icon:
364
390
  // Claude Code's internal helpers (prompt suggestions, side questions)
365
391
  // stop without ever having started here.
@@ -376,9 +402,6 @@ export class Session extends EventEmitter {
376
402
  kind: String(report.kind ?? existing?.kind ?? 'agent').slice(0, 40),
377
403
  status,
378
404
  detail: report.detail === undefined ? existing?.detail ?? '' : String(report.detail).slice(0, 200),
379
- // The parent waits for a foreground agent, so a model reported while
380
- // it works belongs to the agent, not to the session.
381
- foreground: report.foreground === undefined ? existing?.foreground ?? false : report.foreground === true,
382
405
  startedAt: existing?.startedAt ?? now,
383
406
  updatedAt: now,
384
407
  source,
@@ -425,13 +448,142 @@ export class Session extends EventEmitter {
425
448
  const isAgent = report && typeof report === 'object' &&
426
449
  ((report.agentId ?? report.agent ?? report.id) !== undefined || report.finishForeground === true);
427
450
  if (isAgent) this.reportAgent(report, 'terminal');
428
- else if (report?.toolSessionId !== undefined) this.reportToolSession(report);
429
- else this.reportModel(report);
451
+ else if (report?.toolSessionId !== undefined) this.reportToolSession(report, 'terminal');
452
+ else this.reportModel(report, 'terminal');
430
453
  } catch (err) {
431
454
  this.emit('warning', `ignored in-band report: ${err.message}`);
432
455
  }
433
456
  }
434
457
 
458
+ // ---- shells ------------------------------------------------------------
459
+
460
+ reportShell(report) {
461
+ if (!report || typeof report !== 'object') throw badRequest('shell report must be an object');
462
+ if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
463
+ if (!SHELL_EVENTS.has(report.shell)) throw badRequest(`shell must be one of ${[...SHELL_EVENTS].join(', ')}`);
464
+ const id = (value) => (typeof value === 'string' && value ? value.slice(0, 128) : null);
465
+ const key = id(report.key);
466
+ const task = id(report.task);
467
+ const match = typeof report.match === 'string' && /^[a-f0-9]{32}$/.test(report.match) ? report.match : null;
468
+ const agentId = id(report.agentId);
469
+ this._reportingHeard();
470
+
471
+ if (report.shell === 'reset') {
472
+ for (const shell of [...this.shells.values()]) this._endShell(shell);
473
+ return null;
474
+ }
475
+
476
+ if (report.shell === 'running') {
477
+ if (!Array.isArray(report.tasks)) throw badRequest('a running report needs a tasks array');
478
+ const running = new Set(report.tasks.slice(0, MAX_SHELLS).map(id).filter(Boolean));
479
+ for (const shell of [...this.shells.values()]) if (shell.task && !running.has(shell.task)) this._endShell(shell);
480
+ const known = new Set([...this.shells.values()].map((shell) => shell.task));
481
+ for (const each of running) if (!known.has(each) && !this._endedTasks.has(each)) this._addShell({ task: each }, { now: true });
482
+ return null;
483
+ }
484
+
485
+ if (report.shell === 'waiting' || report.shell === 'asked') {
486
+ // A permission request carries no call id, and a PreToolUse hook may have rewritten its command: it belongs to the
487
+ // agent's one foreground command with that command, else to its only one.
488
+ const candidates = [...this.shells.values()].filter((shell) => shell.agentId === agentId && this._foreground(shell) && !shell.waiting && !shell.asked);
489
+ const exact = match ? candidates.filter((shell) => shell.match === match) : [];
490
+ const shell = exact.length === 1 ? exact[0] : exact.length === 0 && candidates.length === 1 ? candidates[0] : null;
491
+ if (shell && report.shell === 'asked') {
492
+ shell.asked = true;
493
+ } else if (shell) {
494
+ clearTimeout(shell.timer);
495
+ shell.waiting = true;
496
+ this._setVisible(shell, false);
497
+ }
498
+ return null;
499
+ }
500
+
501
+ if (report.shell === 'end') {
502
+ if (!key && !task) throw badRequest('a shell end needs a key or a task');
503
+ if (task) this._rememberEnded(task);
504
+ const shell = (key && this._shellBy('key', key)) || (task && this._shellBy('task', task));
505
+ if (shell) this._endShell(shell);
506
+ return null;
507
+ }
508
+
509
+ if (!key) throw badRequest('a shell report needs a key');
510
+ if (report.shell === 'start') {
511
+ if (!this._shellBy('key', key)) this._addShell({ key, match, agentId, persist: report.persist === true });
512
+ return null;
513
+ }
514
+
515
+ if (!task) throw badRequest('a background report needs a task');
516
+ const shell = this._shellBy('key', key);
517
+ if (!shell) return null;
518
+ Object.assign(shell, { task, endsWithAgent: report.endsWithAgent === true });
519
+ if (shell.waiting) {
520
+ shell.waiting = false;
521
+ this._showAfterDelay(shell);
522
+ }
523
+ return null;
524
+ }
525
+
526
+ _addShell(fields, { now = false } = {}) {
527
+ if (this.shells.size >= MAX_SHELLS) {
528
+ this.emit('warning', `ignored a shell command: ${MAX_SHELLS} are already running`);
529
+ return;
530
+ }
531
+ const shell = {
532
+ id: `shell-${++this._shellSeq}`, key: null, match: null, agentId: null, persist: false, task: null,
533
+ endsWithAgent: false, waiting: false, asked: false, visible: false, timer: null, ...fields,
534
+ };
535
+ this.shells.set(shell.id, shell);
536
+ if (now) this._setVisible(shell, true);
537
+ else this._showAfterDelay(shell);
538
+ }
539
+
540
+ _showAfterDelay(shell) {
541
+ clearTimeout(shell.timer);
542
+ shell.timer = setTimeout(() => this._setVisible(shell, true), this.shellDisplayDelayMs);
543
+ shell.timer.unref?.();
544
+ }
545
+
546
+ _setVisible(shell, visible) {
547
+ if (shell.visible === visible) return;
548
+ shell.visible = visible;
549
+ this._changed();
550
+ }
551
+
552
+ _foreground(shell) {
553
+ return shell.task === null;
554
+ }
555
+
556
+ _shellBy(field, value) {
557
+ for (const shell of this.shells.values()) if (shell[field] === value) return shell;
558
+ return null;
559
+ }
560
+
561
+ // A turn's foreground commands end with it; a background command, or one Codex CLI keeps running, does not.
562
+ _endForegroundShells(agentId) {
563
+ for (const shell of [...this.shells.values()]) {
564
+ if (shell.agentId !== agentId) continue;
565
+ const lasting = this._foreground(shell) ? shell.persist && !shell.asked : !(agentId && shell.endsWithAgent);
566
+ if (!lasting) this._endShell(shell);
567
+ }
568
+ }
569
+
570
+ _rememberEnded(task) {
571
+ this._endedTasks.delete(task);
572
+ this._endedTasks.add(task);
573
+ if (this._endedTasks.size > MAX_ENDED_TASKS) this._endedTasks.delete(this._endedTasks.values().next().value);
574
+ }
575
+
576
+ _endShell(shell) {
577
+ clearTimeout(shell.timer);
578
+ if (shell.task) this._rememberEnded(shell.task);
579
+ if (this.shells.delete(shell.id) && shell.visible) this._changed();
580
+ }
581
+
582
+ _clearShells() {
583
+ for (const shell of this.shells.values()) clearTimeout(shell.timer);
584
+ this.shells.clear();
585
+ }
586
+
435
587
  // ---- model -------------------------------------------------------------
436
588
 
437
589
  /**
@@ -446,23 +598,21 @@ export class Session extends EventEmitter {
446
598
  this._changed();
447
599
  }
448
600
 
449
- reportModel(report) {
601
+ reportModel(report, source = 'api') {
450
602
  if (!report || typeof report !== 'object') throw badRequest('model report must be an object');
451
603
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
604
+ if (source === 'api') this._reportingHeard();
452
605
  const name = String(report.model ?? '').trim().slice(0, 120);
453
606
  if (!name) throw badRequest('model is required');
454
607
  const displayName = report.displayName === undefined || report.displayName === null ? null : String(report.displayName).trim().slice(0, 80) || null;
455
- // Gemini CLI fires BeforeModel for a sub-agent's requests too, with the
456
- // sub-agent's model and nothing to tell them apart. While a foreground
457
- // agent works its parent makes no request, so the report is the agent's.
458
- if (this._foregroundAgentWorking()) return this.model;
459
608
  this.setModel({ name, displayName }, 'report');
460
609
  return this.model;
461
610
  }
462
611
 
463
- reportToolSession(report) {
612
+ reportToolSession(report, source = 'api') {
464
613
  if (!report || typeof report !== 'object') throw badRequest('tool session report must be an object');
465
614
  if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
615
+ if (source === 'api') this._reportingHeard();
466
616
  const id = String(report.toolSessionId ?? '').trim();
467
617
  if (!id || id.length > MAX_TOOL_SESSION_ID || /\p{Cc}/u.test(id)) throw badRequest(`toolSessionId must be a printable id of at most ${MAX_TOOL_SESSION_ID} characters`);
468
618
  if (this.toolSessionId !== id) {
@@ -472,11 +622,17 @@ export class Session extends EventEmitter {
472
622
  return this.toolSessionId;
473
623
  }
474
624
 
475
- _foregroundAgentWorking() {
476
- for (const agent of this.agents.values()) {
477
- if (agent.foreground && agent.status === 'working') return true;
478
- }
479
- return false;
625
+ reportHello() {
626
+ if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
627
+ this._reportingHeard();
628
+ return this.reporting;
629
+ }
630
+
631
+ _reportingHeard() {
632
+ clearTimeout(this._reportingTimer);
633
+ if (!this.reporting || this.reporting.state === 'active') return;
634
+ this.reporting = { state: 'active', reason: null };
635
+ this._changed();
480
636
  }
481
637
 
482
638
  /**
@@ -539,7 +695,9 @@ export class Session extends EventEmitter {
539
695
  attachedClients: this.subscribers.size,
540
696
  model: this.model,
541
697
  toolSessionId: this.toolSessionId,
698
+ reporting: this.reporting,
542
699
  agents: [...this.agents.values()],
700
+ shells: [...this.shells.values()].filter((s) => s.visible).map((s) => ({ id: s.id })),
543
701
  };
544
702
  }
545
703
  }
@@ -12,10 +12,6 @@ const RATE_LIMITED_TTL_MS = 5 * 60 * 1000;
12
12
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
13
13
  const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
14
14
  const CLAUDE_KEYCHAIN_SERVICE = 'Claude Code-credentials';
15
- const GEMINI_KEYCHAIN_SERVICE = 'gemini-cli-oauth';
16
- const GEMINI_KEYCHAIN_ACCOUNT = 'main-account';
17
- const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal';
18
- const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
19
15
 
20
16
  export class UsageError extends Error {}
21
17
 
@@ -258,232 +254,6 @@ function codexWindows(limits) {
258
254
  return windows;
259
255
  }
260
256
 
261
- // ---- Gemini CLI -----------------------------------------------------------
262
-
263
- export function geminiCredentialsFile(env = process.env) {
264
- return path.join(env.GEMINI_CLI_HOME || os.homedir(), '.gemini', 'oauth_creds.json');
265
- }
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
-
275
- /**
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.
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
-
298
- export function geminiKeychainLookup(platform) {
299
- if (platform === 'darwin') return { file: 'security', args: ['find-generic-password', '-s', GEMINI_KEYCHAIN_SERVICE, '-a', GEMINI_KEYCHAIN_ACCOUNT, '-w'] };
300
- // keytar stores libsecret items with the attributes "service" and "account".
301
- if (platform === 'linux') return { file: 'secret-tool', args: ['lookup', 'service', GEMINI_KEYCHAIN_SERVICE, 'account', GEMINI_KEYCHAIN_ACCOUNT] };
302
- return null;
303
- }
304
-
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) {
311
- const spec = geminiKeychainLookup(platform);
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
- }
323
- }
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
- */
337
- export async function readGeminiCredentials({
338
- file = geminiCredentialsFile(),
339
- keychainFile = geminiKeychainFile(),
340
- platform = process.platform,
341
- encrypted = false,
342
- fileStorage = false,
343
- readKeychain = readGeminiKeychainItem,
344
- readFileKeychain = readGeminiFileKeychain,
345
- } = {}) {
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
- }
353
- if (raw && raw.trim()) {
354
- let item;
355
- try { item = JSON.parse(raw); } catch { throw new UsageError('Gemini CLI credentials could not be parsed'); }
356
- const token = item?.token ?? item;
357
- if (typeof token?.accessToken !== 'string' || !token.accessToken) throw new UsageError('Gemini CLI credentials could not be parsed');
358
- return { accessToken: token.accessToken, refreshToken: token.refreshToken || null, expiresAt: Number(token.expiresAt) || null, client: null };
359
- }
360
- let legacy;
361
- try {
362
- legacy = JSON.parse(await fs.promises.readFile(file, 'utf8'));
363
- } catch {
364
- throw notSignedIn(`Gemini CLI is not signed in on this machine (no "${GEMINI_KEYCHAIN_SERVICE}" keychain item or ${shortPath(file)})`);
365
- }
366
- if (typeof legacy?.access_token !== 'string' || !legacy.access_token) throw new UsageError('Gemini CLI credentials could not be parsed');
367
- const client = typeof legacy.client_id === 'string' && typeof legacy.client_secret === 'string'
368
- ? { id: legacy.client_id, secret: legacy.client_secret }
369
- : null;
370
- return { accessToken: legacy.access_token, refreshToken: legacy.refresh_token || null, expiresAt: Number(legacy.expiry_date) || null, client };
371
- }
372
-
373
- const geminiClients = new Map();
374
-
375
- /**
376
- * The OAuth client Gemini CLI signed the user in with, read from the
377
- * installed copy that `commandPath` starts. A refresh token can only be
378
- * refreshed with the client that issued it, and the client belongs to
379
- * Gemini CLI, so it is not kept here.
380
- */
381
- export function geminiOAuthClientFromInstall(commandPath) {
382
- if (!commandPath) return null;
383
- if (geminiClients.has(commandPath)) return geminiClients.get(commandPath);
384
- let client = null;
385
- try {
386
- let start = fs.realpathSync(commandPath);
387
- if (/\.(cmd|bat|ps1)$/i.test(start)) {
388
- // npm's shims start node_modules/... relative to their own folder.
389
- const refersToGemini = /node_modules[\\/]@google[\\/]gemini-cli/.test(fs.readFileSync(start, 'utf8'));
390
- start = refersToGemini ? path.join(path.dirname(start), 'node_modules', '@google', 'gemini-cli') : null;
391
- }
392
- let dir = start && (fs.statSync(start).isDirectory() ? start : path.dirname(start));
393
- for (let depth = 0; dir && depth < 8; depth++) {
394
- let name = null;
395
- try { name = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name; } catch { /* keep climbing */ }
396
- if (name === '@google/gemini-cli') break;
397
- const parent = path.dirname(dir);
398
- dir = parent === dir ? null : parent;
399
- }
400
- for (const sub of dir ? ['bundle', 'dist'] : []) {
401
- let files = [];
402
- try { files = fs.readdirSync(path.join(dir, sub)).filter((f) => f.endsWith('.js')); } catch { continue; }
403
- for (const file of files) {
404
- const text = fs.readFileSync(path.join(dir, sub, file), 'latin1');
405
- const id = text.match(/OAUTH_CLIENT_ID\s*=\s*"([^"]+)"/)?.[1];
406
- const secret = text.match(/OAUTH_CLIENT_SECRET\s*=\s*"([^"]+)"/)?.[1];
407
- if (id && secret) { client = { id, secret }; break; }
408
- }
409
- if (client) break;
410
- }
411
- } catch {
412
- client = null;
413
- }
414
- geminiClients.set(commandPath, client);
415
- return client;
416
- }
417
-
418
- async function refreshGoogleToken(refreshToken, client, fetchImpl) {
419
- const body = new URLSearchParams({
420
- client_id: client.id,
421
- client_secret: client.secret,
422
- refresh_token: refreshToken,
423
- grant_type: 'refresh_token',
424
- });
425
- const res = await fetchImpl(GOOGLE_TOKEN_URL, {
426
- method: 'POST',
427
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
428
- body: body.toString(),
429
- signal: AbortSignal.timeout(10000),
430
- });
431
- if (!res.ok) throw new UsageError('Gemini CLI sign-in could not be refreshed; run gemini once to sign in again');
432
- const json = await res.json();
433
- if (typeof json?.access_token !== 'string') throw new UsageError('Gemini CLI sign-in could not be refreshed; run gemini once to sign in again');
434
- return { accessToken: json.access_token, expiresAt: Date.now() + (Number(json.expires_in) || 3600) * 1000 };
435
- }
436
-
437
- /**
438
- * Asks the Code Assist API the way Gemini CLI does: loadCodeAssist for the
439
- * account's project and tier (skipped when `project` is known), then
440
- * retrieveUserQuota for one bucket per model. Returns the refreshed token
441
- * and project too, so a caller can reuse them.
442
- */
443
- export async function fetchGeminiUsage({
444
- accessToken, refreshToken = null, expiresAt = null, client = null, project = null, version = null, fetchImpl = fetch,
445
- } = {}) {
446
- let token = { accessToken, expiresAt };
447
- if (expiresAt && expiresAt < Date.now() + 60000) {
448
- if (!refreshToken || !client) throw new UsageError('Gemini CLI sign-in has expired; run gemini once to refresh it');
449
- token = await refreshGoogleToken(refreshToken, client, fetchImpl);
450
- }
451
- const headers = {
452
- Authorization: `Bearer ${token.accessToken}`,
453
- 'Content-Type': 'application/json',
454
- Accept: 'application/json',
455
- 'User-Agent': `GeminiCLI/${version || '0.62.0'} (agent-guild)`,
456
- };
457
- const post = async (method, body) => {
458
- const res = await fetchImpl(`${GEMINI_CODE_ASSIST_URL}:${method}`, {
459
- method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(10000),
460
- });
461
- if (!res.ok) throw httpUsageError(res.status, 'sign in again in Gemini CLI');
462
- return res.json();
463
- };
464
- let plan = null;
465
- let projectId = project;
466
- if (!projectId) {
467
- const loaded = await post('loadCodeAssist', {
468
- metadata: { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' },
469
- });
470
- const found = loaded?.cloudaicompanionProject;
471
- projectId = typeof found === 'string' ? found : typeof found?.id === 'string' ? found.id : null;
472
- // A paid tier is the effective subscription, as Gemini CLI reads it.
473
- const tier = loaded?.paidTier?.id || loaded?.paidTier?.name ? loaded.paidTier : loaded?.currentTier;
474
- plan = tier?.name ?? tier?.id ?? null;
475
- if (!projectId) throw new UsageError('Gemini CLI has no Code Assist project yet; finish signing in with gemini first');
476
- }
477
- const quota = await post('retrieveUserQuota', { project: projectId });
478
- const windows = [];
479
- for (const bucket of Array.isArray(quota?.buckets) ? quota.buckets : []) {
480
- if (!bucket?.modelId || typeof bucket.remainingFraction !== 'number') continue;
481
- const used = clampPercent((1 - bucket.remainingFraction) * 100);
482
- if (used !== null) windows.push({ label: String(bucket.modelId).slice(0, 40), usedPercent: used, resetsAt: toIso(bucket.resetTime) });
483
- }
484
- return { plan, windows, project: projectId, token };
485
- }
486
-
487
257
  // ---- any command that prints JSON ------------------------------------------
488
258
 
489
259
  export async function commandUsage({ command, args = [] }, env, platform = process.platform) {
@@ -532,9 +302,8 @@ export class UsageMonitor {
532
302
  this.platform = platform;
533
303
  this.fetchImpl = fetchImpl;
534
304
  this.ttlMs = ttlMs;
535
- this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, gemini: readGeminiCredentials, ...readers };
305
+ this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, ...readers };
536
306
  this.cache = new Map();
537
- this.gemini = new Map();
538
307
  }
539
308
 
540
309
  /** Snapshots for every account of every provider that has a usage source. */
@@ -553,7 +322,7 @@ export class UsageMonitor {
553
322
  const now = Date.now();
554
323
  if (entry?.inflight) return entry.inflight;
555
324
  if (entry && now - entry.at < entry.ttl) return Promise.resolve(entry.snapshot);
556
- const inflight = this._fetch(provider, account, key).then((snapshot) => {
325
+ const inflight = this._fetch(provider, account).then((snapshot) => {
557
326
  const ttl = snapshot.rateLimited ? RATE_LIMITED_TTL_MS : this.ttlMs;
558
327
  this.cache.set(key, { snapshot, at: Date.now(), ttl });
559
328
  return snapshot;
@@ -562,7 +331,7 @@ export class UsageMonitor {
562
331
  return inflight;
563
332
  }
564
333
 
565
- async _fetch(provider, account, key) {
334
+ async _fetch(provider, account) {
566
335
  const base = { providerId: provider.id, accountId: account.id, plan: null, windows: [], credits: null, signedIn: null, fetchedAt: new Date().toISOString(), error: null };
567
336
  const env = { ...this.env, ...provider.env, ...account.env };
568
337
  let signedIn = null;
@@ -580,30 +349,6 @@ export class UsageMonitor {
580
349
  const creds = await this.readers.codex({ file: codexAuthFile(env) });
581
350
  signedIn = true;
582
351
  result = await fetchCodexUsage({ ...creds, fetchImpl: this.fetchImpl });
583
- } else if (provider.usage === '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;
591
- // The refreshed token, project and plan belong to one sign-in; a new
592
- // sign-in (another account, or the same one again) starts over.
593
- const signIn = creds.refreshToken || creds.accessToken;
594
- const cached = this.gemini.get(key);
595
- const known = cached?.signIn === signIn ? cached : {};
596
- const fresh = known.token && known.token.expiresAt > Date.now() + 60000 ? known.token : null;
597
- const { plan, windows, project, token } = await fetchGeminiUsage({
598
- ...creds,
599
- client: creds.client || geminiOAuthClientFromInstall(this.registry.resolve(provider)),
600
- ...(fresh || {}),
601
- project: env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID || known.project || null,
602
- version: this.registry.versions.get(provider.id)?.installed,
603
- fetchImpl: this.fetchImpl,
604
- });
605
- this.gemini.set(key, { signIn, project, token, plan: plan ?? known.plan ?? null });
606
- result = { plan: plan ?? known.plan ?? null, windows };
607
352
  } else {
608
353
  result = await commandUsage(provider.usage, env, this.platform);
609
354
  }