agent-tank 0.9.5 → 0.9.6

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 CHANGED
@@ -89,6 +89,9 @@ agent-tank --no-docker
89
89
  # Monitor only specific agents
90
90
  agent-tank --claude --gemini
91
91
 
92
+ # Prefer direct Gemini quota fetching
93
+ agent-tank --gemini --gemini-mode direct
94
+
92
95
  # Use a custom port
93
96
  agent-tank --port 8080
94
97
 
@@ -185,6 +188,7 @@ Options:
185
188
  --claude Enable Claude monitoring
186
189
  --gemini Enable Gemini monitoring
187
190
  --codex Enable Codex monitoring
191
+ --gemini-mode <mode> Gemini strategy: fallback, direct, pty (default: fallback)
188
192
  --port <port> HTTP server port (default: 3456)
189
193
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
190
194
  --docker Enable Docker bridge bind when --host is omitted (default: true)
@@ -224,6 +228,7 @@ Environment variables override CLI flags and config file values.
224
228
  | `AGENT_TANK_DOCKER` | Enable/disable Docker bridge bind when `--host` is omitted |
225
229
  | `AGENT_TANK_FRESH_PROCESS` | Use fresh process per refresh (`1`/`true`) |
226
230
  | `AGENT_TANK_CLAUDE_API` | Use Claude API mode (`1`/`true`) |
231
+ | `AGENT_TANK_GEMINI_MODE` | Gemini strategy: `fallback`, `direct`, or `pty` |
227
232
  | `AGENT_TANK_AUTO_REFRESH` | Enable/disable auto-refresh |
228
233
  | `AGENT_TANK_AUTO_REFRESH_MODE` | `none`, `interval`, or `activity` |
229
234
  | `AGENT_TANK_AUTO_REFRESH_INTERVAL` | Auto-refresh interval in seconds |
@@ -243,6 +248,7 @@ Environment variables override CLI flags and config file values.
243
248
  "port": 8080,
244
249
  "dockerAccess": true,
245
250
  "claudeApi": false,
251
+ "geminiMode": "fallback",
246
252
  "refreshCooldown": 30,
247
253
  "autoRefresh": {
248
254
  "mode": "activity",
@@ -266,6 +272,28 @@ Run with:
266
272
  agent-tank -c config.json
267
273
  ```
268
274
 
275
+ ## Gemini Modes
276
+
277
+ By default, Gemini runs in `fallback` mode:
278
+
279
+ - try a direct quota fetch using the local Gemini OAuth session
280
+ - fall back to PTY `/stats` if the direct path fails
281
+
282
+ Available modes:
283
+
284
+ - `fallback`: direct first, PTY fallback
285
+ - `direct`: use the direct quota API only
286
+ - `pty`: use interactive Gemini CLI `/stats` only
287
+
288
+ In `--once` mode, Agent Tank prints the result and exits immediately after the output is flushed.
289
+
290
+ Examples:
291
+
292
+ ```bash
293
+ agent-tank --gemini --gemini-mode direct
294
+ agent-tank --gemini --gemini-mode pty
295
+ ```
296
+
269
297
  ## HTTP API
270
298
 
271
299
  ### Endpoints
package/bin/agent-tank.js CHANGED
@@ -11,6 +11,7 @@ const options = {
11
11
  gemini: { type: 'boolean', default: false },
12
12
  codex: { type: 'boolean', default: false },
13
13
  'claude-api': { type: 'boolean', default: false },
14
+ 'gemini-mode': { type: 'string' },
14
15
  port: { type: 'string', default: '3456' },
15
16
  host: { type: 'string' },
16
17
  docker: { type: 'boolean', default: true },
@@ -43,6 +44,17 @@ function exitWithCode(code, message, stream = process.stderr) {
43
44
  process.exitCode = code;
44
45
  }
45
46
 
47
+ function writeAndExit(stream, text, exitCode, { cleanup, watcher, jsonMode, originalLog }) {
48
+ stream.write(text, () => {
49
+ cleanup();
50
+ watcher.stop();
51
+ if (jsonMode) {
52
+ console.log = originalLog;
53
+ }
54
+ process.exit(exitCode);
55
+ });
56
+ }
57
+
46
58
  function printHelp() {
47
59
  process.stdout.write(`
48
60
  agent-tank - Monitor LLM CLI usage limits
@@ -54,6 +66,7 @@ Options:
54
66
  --gemini Enable Gemini monitoring
55
67
  --codex Enable Codex monitoring
56
68
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
69
+ --gemini-mode <mode> Gemini strategy: fallback, direct, pty (default: fallback)
57
70
  --port <port> HTTP server port (default: 3456)
58
71
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
59
72
  --docker Enable Docker bridge bind when --host is omitted (default: true)
@@ -90,6 +103,7 @@ Environment variables:
90
103
  AGENT_TANK_DOCKER Enable/disable Docker bridge bind ("1"/"true" or "0"/"false")
91
104
  AGENT_TANK_FRESH_PROCESS Use fresh process per refresh ("1" or "true")
92
105
  AGENT_TANK_CLAUDE_API Use direct Anthropic API for Claude usage ("1" or "true")
106
+ AGENT_TANK_GEMINI_MODE Gemini strategy: fallback, direct, pty
93
107
  AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
94
108
  AGENT_TANK_AUTO_REFRESH_MODE Refresh mode: none, interval, activity
95
109
  AGENT_TANK_AUTO_REFRESH_INTERVAL Auto-refresh interval in seconds
@@ -105,6 +119,7 @@ Examples:
105
119
  agent-tank --port 8080 # Use custom port
106
120
  agent-tank --host 0.0.0.0 # Expose on all interfaces
107
121
  agent-tank --no-docker # Bind localhost only when host is omitted
122
+ agent-tank --gemini-mode direct # Prefer direct Gemini quota API calls
108
123
  agent-tank --auth-user admin --auth-pass secret # Enable basic auth
109
124
  agent-tank --auth-token mykey # Enable API key auth
110
125
  agent-tank -c ./config.json # Use config file
@@ -188,6 +203,9 @@ async function main() {
188
203
  config.claudeApi ||
189
204
  claudeApiEnv === '1' || claudeApiEnv === 'true';
190
205
 
206
+ const geminiModeEnv = process.env.AGENT_TANK_GEMINI_MODE;
207
+ const geminiMode = geminiModeEnv || values['gemini-mode'] || config.geminiMode || 'fallback';
208
+
191
209
  // Auto-refresh configuration (env > CLI > config file)
192
210
  const autoRefreshEnv = process.env.AGENT_TANK_AUTO_REFRESH;
193
211
  let autoRefreshEnabled = values['auto-refresh'];
@@ -280,6 +298,7 @@ async function main() {
280
298
  auth,
281
299
  freshProcess,
282
300
  claudeApi,
301
+ geminiMode,
283
302
  autoRefreshEnabled: onceMode ? false : autoRefreshEnabled, // Disable auto-refresh in one-shot mode
284
303
  autoRefreshInterval,
285
304
  autoRefreshMode: onceMode ? 'none' : autoRefreshMode, // Disable auto-refresh in one-shot mode
@@ -311,21 +330,28 @@ async function main() {
311
330
  await watcher.start();
312
331
  const status = watcher.getStatus();
313
332
  if (jsonMode) {
314
- // Restore console.log for JSON output
315
- console.log = originalLog;
316
- console.log(JSON.stringify(status, null, 2));
317
- // Suppress console.log again before stopping to avoid extra shutdown output
318
- console.log = () => {};
333
+ const output = `${JSON.stringify(status, null, 2)}\n`;
334
+ writeAndExit(process.stdout, output, 0, {
335
+ cleanup: cleanupShutdownHandlers,
336
+ watcher,
337
+ jsonMode,
338
+ originalLog,
339
+ });
340
+ return;
319
341
  } else {
342
+ let output = '';
320
343
  for (const [name, agentStatus] of Object.entries(status)) {
321
- originalLog(`\n=== ${name.toUpperCase()} ===`);
322
- originalLog(JSON.stringify(agentStatus, null, 2));
344
+ output += `\n=== ${name.toUpperCase()} ===\n`;
345
+ output += `${JSON.stringify(agentStatus, null, 2)}\n`;
323
346
  }
347
+ writeAndExit(process.stdout, output, 0, {
348
+ cleanup: cleanupShutdownHandlers,
349
+ watcher,
350
+ jsonMode,
351
+ originalLog,
352
+ });
353
+ return;
324
354
  }
325
- cleanupShutdownHandlers();
326
- watcher.stop();
327
- process.exitCode = 0;
328
- return;
329
355
  }
330
356
 
331
357
  await watcher.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Monitor and query usage limits for LLM CLI tools (Claude, Gemini, Codex) via HTTP API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -231,6 +231,10 @@ class BaseAgent {
231
231
  clearTimeout(timer);
232
232
  spawnDataHandler.dispose();
233
233
  spawnExitHandler.dispose();
234
+ if (!this.shell) {
235
+ reject(new Error('Process exited during spawn before handlers were attached'));
236
+ return;
237
+ }
234
238
  this.processReady = true;
235
239
  this.output = spawnOutput;
236
240
  this._setupPersistentDataHandler();
@@ -243,6 +247,11 @@ class BaseAgent {
243
247
  logger.agent(this.name, 'Process ready for commands');
244
248
  clearTimeout(timer);
245
249
  spawnDataHandler.dispose();
250
+ spawnExitHandler.dispose();
251
+ if (!this.shell) {
252
+ reject(new Error('Process exited during spawn before handlers were attached'));
253
+ return;
254
+ }
246
255
  this.processReady = true;
247
256
  this._setupPersistentDataHandler();
248
257
  this._setupPersistentExitHandler();
@@ -334,6 +343,10 @@ class BaseAgent {
334
343
  }
335
344
 
336
345
  _setupPersistentDataHandler() {
346
+ if (!this.shell) {
347
+ logger.agent(this.name, 'Skipping persistent data handler setup: shell already exited');
348
+ return;
349
+ }
337
350
  const handler = this.shell.onData((data) => {
338
351
  this.output += data;
339
352
 
@@ -356,6 +369,10 @@ class BaseAgent {
356
369
  }
357
370
 
358
371
  _setupPersistentExitHandler() {
372
+ if (!this.shell) {
373
+ logger.agent(this.name, 'Skipping persistent exit handler setup: shell already exited');
374
+ return;
375
+ }
359
376
  const handler = this.shell.onExit(({ exitCode }) => {
360
377
  logger.agent(this.name, 'Persistent process exited with code', logger.dim(exitCode));
361
378
  this.shell = null; this.processReady = false; this._onDataCallback = null; this._disposables = [];
@@ -0,0 +1,234 @@
1
+ const fs = require('node:fs');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+
5
+ const GEMINI_DIR = '.gemini';
6
+ const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9' + 'e3aqf6av3hmdib135j.apps.googleusercontent.com';
7
+ const OAUTH_CLIENT_SECRET = 'GOCSPX-4uHgMPm-1o7Sk-' + 'geV6Cu5clXFsxl';
8
+ const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
9
+ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal';
10
+ const REFRESH_SKEW_MS = 60 * 1000;
11
+ const VALID_GEMINI_MODES = new Set(['pty', 'direct', 'fallback']);
12
+
13
+ function getGeminiHomeDir() {
14
+ return path.join(os.homedir(), GEMINI_DIR);
15
+ }
16
+
17
+ function getOAuthCredsPath() {
18
+ return path.join(getGeminiHomeDir(), 'oauth_creds.json');
19
+ }
20
+
21
+ function getGoogleAccountsPath() {
22
+ return path.join(getGeminiHomeDir(), 'google_accounts.json');
23
+ }
24
+
25
+ function normalizeGeminiMode(mode) {
26
+ const normalized = typeof mode === 'string' ? mode.toLowerCase() : 'fallback';
27
+ return VALID_GEMINI_MODES.has(normalized) ? normalized : 'fallback';
28
+ }
29
+
30
+ function readJsonIfExists(filePath) {
31
+ try {
32
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
33
+ } catch (err) {
34
+ if (err && err.code === 'ENOENT') {
35
+ return null;
36
+ }
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ function formatDurationFromSeconds(totalSeconds) {
42
+ if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
43
+ return '0m';
44
+ }
45
+
46
+ const days = Math.floor(totalSeconds / 86400);
47
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
48
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
49
+
50
+ if (days > 0) {
51
+ return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
52
+ }
53
+
54
+ if (hours > 0) {
55
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
56
+ }
57
+
58
+ return `${Math.max(1, minutes)}m`;
59
+ }
60
+
61
+ function parseResetTime(resetTime) {
62
+ if (!resetTime) {
63
+ return { resetsIn: null, resetsInSeconds: null };
64
+ }
65
+
66
+ const resetAtMs = Date.parse(resetTime);
67
+ if (!Number.isFinite(resetAtMs)) {
68
+ return { resetsIn: null, resetsInSeconds: null };
69
+ }
70
+
71
+ const resetsInSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
72
+ return {
73
+ resetsIn: formatDurationFromSeconds(resetsInSeconds),
74
+ resetsInSeconds,
75
+ };
76
+ }
77
+
78
+ function buildModelEntry(bucket) {
79
+ if (!bucket || !bucket.modelId || bucket.remainingFraction == null) {
80
+ return null;
81
+ }
82
+
83
+ const usageLeft = Number((bucket.remainingFraction * 100).toFixed(1));
84
+ const percentUsed = Number((100 - usageLeft).toFixed(1));
85
+ const { resetsIn, resetsInSeconds } = parseResetTime(bucket.resetTime);
86
+
87
+ return {
88
+ model: bucket.modelId,
89
+ usageLeft,
90
+ percentUsed,
91
+ resetsIn,
92
+ resetsInSeconds,
93
+ };
94
+ }
95
+
96
+ function buildUsageFromQuota(quotaResponse) {
97
+ const seenModels = new Set();
98
+ const models = [];
99
+
100
+ for (const bucket of quotaResponse?.buckets || []) {
101
+ const entry = buildModelEntry(bucket);
102
+ if (!entry || seenModels.has(entry.model)) {
103
+ continue;
104
+ }
105
+ seenModels.add(entry.model);
106
+ models.push(entry);
107
+ }
108
+
109
+ return { models };
110
+ }
111
+
112
+ class GeminiDirectQuotaClient {
113
+ constructor(options = {}) {
114
+ this.fetchImpl = options.fetchImpl || global.fetch;
115
+ this.now = options.now || Date.now;
116
+ }
117
+
118
+ _readOAuthCredentials() {
119
+ const creds = readJsonIfExists(getOAuthCredsPath());
120
+ if (!creds) {
121
+ throw new Error(`Gemini OAuth credentials not found at ${getOAuthCredsPath()}`);
122
+ }
123
+ return creds;
124
+ }
125
+
126
+ _readActiveAccountEmail() {
127
+ const accounts = readJsonIfExists(getGoogleAccountsPath());
128
+ return accounts?.active || null;
129
+ }
130
+
131
+ async _getAccessToken() {
132
+ const creds = this._readOAuthCredentials();
133
+ const expiryDate = Number(creds.expiry_date);
134
+ const accessToken = creds.access_token;
135
+
136
+ if (accessToken && Number.isFinite(expiryDate) && expiryDate - REFRESH_SKEW_MS > this.now()) {
137
+ return accessToken;
138
+ }
139
+
140
+ if (!creds.refresh_token) {
141
+ throw new Error('Gemini OAuth credentials are missing a refresh token');
142
+ }
143
+
144
+ const body = new URLSearchParams({
145
+ client_id: OAUTH_CLIENT_ID,
146
+ client_secret: OAUTH_CLIENT_SECRET,
147
+ refresh_token: creds.refresh_token,
148
+ grant_type: 'refresh_token',
149
+ });
150
+
151
+ const response = await this.fetchImpl(TOKEN_ENDPOINT, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
+ body,
155
+ });
156
+
157
+ if (!response.ok) {
158
+ const text = await response.text();
159
+ throw new Error(`Failed to refresh Gemini OAuth token (${response.status}): ${text || response.statusText}`);
160
+ }
161
+
162
+ const refreshed = await response.json();
163
+ const updatedCreds = {
164
+ ...creds,
165
+ access_token: refreshed.access_token,
166
+ expiry_date: refreshed.expires_in ? this.now() + (refreshed.expires_in * 1000) : creds.expiry_date,
167
+ token_type: refreshed.token_type || creds.token_type,
168
+ scope: refreshed.scope || creds.scope,
169
+ };
170
+
171
+ fs.writeFileSync(getOAuthCredsPath(), JSON.stringify(updatedCreds, null, 2));
172
+ return updatedCreds.access_token;
173
+ }
174
+
175
+ async _post(method, token, payload) {
176
+ const response = await this.fetchImpl(`${CODE_ASSIST_ENDPOINT}:${method}`, {
177
+ method: 'POST',
178
+ headers: {
179
+ 'Content-Type': 'application/json',
180
+ Authorization: `Bearer ${token}`,
181
+ },
182
+ body: JSON.stringify(payload),
183
+ });
184
+
185
+ if (!response.ok) {
186
+ const text = await response.text();
187
+ throw new Error(`Gemini ${method} request failed (${response.status}): ${text || response.statusText}`);
188
+ }
189
+
190
+ return response.json();
191
+ }
192
+
193
+ async fetchQuotaUsage() {
194
+ const token = await this._getAccessToken();
195
+ const configuredProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
196
+
197
+ const loadResponse = await this._post('loadCodeAssist', token, {
198
+ cloudaicompanionProject: configuredProject,
199
+ metadata: {
200
+ ideType: 'IDE_UNSPECIFIED',
201
+ platform: 'PLATFORM_UNSPECIFIED',
202
+ pluginType: 'GEMINI',
203
+ duetProject: configuredProject,
204
+ },
205
+ mode: 'HEALTH_CHECK',
206
+ });
207
+
208
+ const project = loadResponse?.cloudaicompanionProject || configuredProject;
209
+ if (!project) {
210
+ throw new Error('Gemini Code Assist project ID is unavailable');
211
+ }
212
+
213
+ const quotaResponse = await this._post('retrieveUserQuota', token, { project });
214
+ const usage = buildUsageFromQuota(quotaResponse);
215
+
216
+ return {
217
+ usage,
218
+ metadata: {
219
+ email: this._readActiveAccountEmail(),
220
+ authMethod: 'OAuth',
221
+ tier: loadResponse?.paidTier?.name || loadResponse?.currentTier?.name,
222
+ project,
223
+ },
224
+ };
225
+ }
226
+ }
227
+
228
+ module.exports = {
229
+ GeminiDirectQuotaClient,
230
+ buildModelEntry,
231
+ buildUsageFromQuota,
232
+ formatDurationFromSeconds,
233
+ normalizeGeminiMode,
234
+ };
@@ -1,14 +1,60 @@
1
1
  const { BaseAgent } = require('./base.js');
2
2
  const { calculatePace } = require('../pace-evaluator.js');
3
3
  const { CYCLE_DURATIONS } = require('../usage-formatters.js');
4
+ const { GeminiDirectQuotaClient, normalizeGeminiMode } = require('./gemini-direct.js');
4
5
  const logger = require('../logger.js');
5
6
 
6
7
  class GeminiAgent extends BaseAgent {
7
- constructor() {
8
- super('gemini', 'gemini');
8
+ constructor(options = {}) {
9
+ super('gemini', 'gemini', ['--skip-trust']);
10
+ this.mode = normalizeGeminiMode(options.mode);
11
+ this.directClient = new GeminiDirectQuotaClient(options.directClientOptions);
9
12
  this._aboutSent = false;
10
13
  }
11
14
 
15
+ async refresh() {
16
+ if (this.mode === 'pty') {
17
+ return super.refresh();
18
+ }
19
+
20
+ if (this.isStopping()) {
21
+ logger.agent(this.name, 'Skipping refresh while stopping');
22
+ return;
23
+ }
24
+
25
+ if (this.isRefreshing) {
26
+ logger.agent(this.name, 'Already refreshing, skipping...');
27
+ return;
28
+ }
29
+
30
+ logger.agent(this.name, `Starting refresh${this.mode === 'direct' ? ' (direct quota mode)' : ' (direct quota mode with PTY fallback)'}...`);
31
+ this.isRefreshing = true;
32
+ this.error = null;
33
+ this.auth = null;
34
+
35
+ try {
36
+ const result = await this.directClient.fetchQuotaUsage();
37
+ this.usage = result.usage;
38
+ this.metadata = result.metadata || this.metadata;
39
+ this._metadataFetched = !!this.metadata;
40
+ this.lastUpdated = new Date().toISOString();
41
+ logger.agent(this.name, 'Parsed direct quota usage:', logger.json(this.usage));
42
+ } catch (err) {
43
+ if (this.mode === 'fallback') {
44
+ logger.agent(this.name, `Direct quota fetch failed, falling back to PTY: ${err.message}`);
45
+ this.isRefreshing = false;
46
+ return super.refresh();
47
+ }
48
+ logger.error(`[${this.name}] Error during direct quota refresh:`, err.message);
49
+ this.error = err.message;
50
+ } finally {
51
+ if (this.isRefreshing) {
52
+ this.isRefreshing = false;
53
+ logger.agent(this.name, 'Refresh complete');
54
+ }
55
+ }
56
+ }
57
+
12
58
  getTimeout() {
13
59
  return 35000; // 35 seconds - Gemini can take 10+ seconds to start
14
60
  }
@@ -388,6 +434,11 @@ class GeminiAgent extends BaseAgent {
388
434
  * @returns {Promise<boolean>} True if keepalive succeeded
389
435
  */
390
436
  async keepalive() {
437
+ if (this.mode === 'direct' || (this.mode === 'fallback' && (!this.shell || !this.processReady))) {
438
+ console.log(`[${this.name}] Keepalive skipped (${this.mode} mode)`);
439
+ return true;
440
+ }
441
+
391
442
  if (this.freshProcess) {
392
443
  console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
393
444
  return true;
package/src/index.js CHANGED
@@ -121,6 +121,7 @@ class AgentTank {
121
121
  this.requestedAgents = options.agents || null;
122
122
  this.freshProcess = options.freshProcess || false;
123
123
  this.claudeApi = options.claudeApi || false; // Use direct Anthropic API for Claude
124
+ this.geminiMode = options.geminiMode || 'fallback';
124
125
  this.auth = options.auth || {};
125
126
  this.agents = new Map();
126
127
  this.server = null;
@@ -327,7 +328,7 @@ class AgentTank {
327
328
  case 'claude':
328
329
  return new ClaudeAgent({ useApi: this.claudeApi });
329
330
  case 'gemini':
330
- return new GeminiAgent();
331
+ return new GeminiAgent({ mode: this.geminiMode });
331
332
  case 'codex':
332
333
  return new CodexAgent();
333
334
  default: