@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.
- package/README.md +22 -26
- package/bin/agent-guild-report.mjs +67 -25
- package/config/providers.default.json +18 -14
- package/examples/grok-hooks.json +43 -0
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +6 -2
- package/src/manager/config.mjs +1 -0
- package/src/manager/install-channels.mjs +7 -0
- package/src/manager/main.mjs +8 -2
- package/src/manager/news.mjs +1 -1
- package/src/manager/providers.mjs +14 -9
- package/src/manager/report-shims.mjs +6 -4
- package/src/manager/server.mjs +18 -3
- package/src/manager/session-history.mjs +32 -74
- package/src/manager/session-hooks.mjs +351 -0
- package/src/manager/session-manager.mjs +27 -19
- package/src/manager/session.mjs +183 -25
- package/src/manager/usage.mjs +3 -258
- package/src/report/hooks.mjs +87 -38
- package/web/app.js +89 -13
- package/web/index.html +5 -0
- package/web/styles.css +10 -2
- package/examples/gemini-settings.json +0 -61
package/src/manager/session.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
}
|
package/src/manager/usage.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
}
|