agent-tank 0.9.4 → 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
@@ -75,7 +75,7 @@ By default it starts on:
75
75
  http://127.0.0.1:3456
76
76
  ```
77
77
 
78
- If Docker is running with a detectable local bridge interface, Agent Tank also binds that bridge address by default so containers on the same host can reach it without exposing it on the public interface.
78
+ If Docker is available, Agent Tank asks Docker for the active bridge gateway addresses and binds them by default so containers on the same host can reach it without exposing it on the public interface. If Docker is unavailable, it falls back to local bridge interface detection.
79
79
 
80
80
  Use this if you want localhost only:
81
81
 
@@ -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
 
@@ -97,6 +100,9 @@ agent-tank --once --json
97
100
 
98
101
  # Show the installed Agent Tank version
99
102
  agent-tank --version
103
+
104
+ # Require at least 60 seconds between refreshes for the same agent
105
+ agent-tank --refresh-cooldown 60
100
106
  ```
101
107
 
102
108
  ## What You Get
@@ -165,6 +171,16 @@ agent-tank --no-docker
165
171
  agent-tank --auto-refresh-mode none
166
172
  ```
167
173
 
174
+ ### Slow Down Agent Refreshes
175
+
176
+ By default, Agent Tank will not refresh the same agent more than once every 30 seconds, even if the refresh is triggered manually or by detected CLI activity. This reduces unnecessary polling and helps avoid CLI-side rate limiting.
177
+
178
+ ```bash
179
+ agent-tank --refresh-cooldown 60
180
+ ```
181
+
182
+ Set it to `0` to disable the cooldown entirely.
183
+
168
184
  ## Command Line Options
169
185
 
170
186
  ```text
@@ -172,6 +188,7 @@ Options:
172
188
  --claude Enable Claude monitoring
173
189
  --gemini Enable Gemini monitoring
174
190
  --codex Enable Codex monitoring
191
+ --gemini-mode <mode> Gemini strategy: fallback, direct, pty (default: fallback)
175
192
  --port <port> HTTP server port (default: 3456)
176
193
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
177
194
  --docker Enable Docker bridge bind when --host is omitted (default: true)
@@ -186,6 +203,7 @@ Options:
186
203
  --auto-refresh Enable/disable background auto-refresh (default: true)
187
204
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
188
205
  --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60)
206
+ --refresh-cooldown <seconds> Minimum time between refreshes per agent (default: 30, 0 = disabled)
189
207
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
190
208
  --keepalive Enable/disable session keepalive (default: true)
191
209
  --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300)
@@ -210,9 +228,11 @@ Environment variables override CLI flags and config file values.
210
228
  | `AGENT_TANK_DOCKER` | Enable/disable Docker bridge bind when `--host` is omitted |
211
229
  | `AGENT_TANK_FRESH_PROCESS` | Use fresh process per refresh (`1`/`true`) |
212
230
  | `AGENT_TANK_CLAUDE_API` | Use Claude API mode (`1`/`true`) |
231
+ | `AGENT_TANK_GEMINI_MODE` | Gemini strategy: `fallback`, `direct`, or `pty` |
213
232
  | `AGENT_TANK_AUTO_REFRESH` | Enable/disable auto-refresh |
214
233
  | `AGENT_TANK_AUTO_REFRESH_MODE` | `none`, `interval`, or `activity` |
215
234
  | `AGENT_TANK_AUTO_REFRESH_INTERVAL` | Auto-refresh interval in seconds |
235
+ | `AGENT_TANK_REFRESH_COOLDOWN` | Minimum time between refreshes per agent in seconds |
216
236
  | `AGENT_TANK_ACTIVITY_DEBOUNCE` | Activity debounce interval in milliseconds |
217
237
  | `AGENT_TANK_KEEPALIVE` | Enable/disable keepalive |
218
238
  | `AGENT_TANK_KEEPALIVE_INTERVAL` | Keepalive interval in seconds |
@@ -228,10 +248,13 @@ Environment variables override CLI flags and config file values.
228
248
  "port": 8080,
229
249
  "dockerAccess": true,
230
250
  "claudeApi": false,
251
+ "geminiMode": "fallback",
252
+ "refreshCooldown": 30,
231
253
  "autoRefresh": {
232
254
  "mode": "activity",
233
255
  "interval": 60,
234
- "activityDebounce": 5000
256
+ "activityDebounce": 5000,
257
+ "refreshCooldown": 30
235
258
  },
236
259
  "keepalive": {
237
260
  "enabled": true,
@@ -249,6 +272,28 @@ Run with:
249
272
  agent-tank -c config.json
250
273
  ```
251
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
+
252
297
  ## HTTP API
253
298
 
254
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 },
@@ -25,6 +26,7 @@ const options = {
25
26
  'auto-refresh': { type: 'boolean', default: true },
26
27
  'auto-refresh-interval': { type: 'string', default: '60' },
27
28
  'auto-refresh-mode': { type: 'string', default: 'activity' },
29
+ 'refresh-cooldown': { type: 'string', default: '30' },
28
30
  'activity-debounce': { type: 'string', default: '5000' },
29
31
  'history-retention-days': { type: 'string', default: '14' },
30
32
  'keepalive': { type: 'boolean', default: true },
@@ -42,6 +44,17 @@ function exitWithCode(code, message, stream = process.stderr) {
42
44
  process.exitCode = code;
43
45
  }
44
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
+
45
58
  function printHelp() {
46
59
  process.stdout.write(`
47
60
  agent-tank - Monitor LLM CLI usage limits
@@ -53,6 +66,7 @@ Options:
53
66
  --gemini Enable Gemini monitoring
54
67
  --codex Enable Codex monitoring
55
68
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
69
+ --gemini-mode <mode> Gemini strategy: fallback, direct, pty (default: fallback)
56
70
  --port <port> HTTP server port (default: 3456)
57
71
  --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
58
72
  --docker Enable Docker bridge bind when --host is omitted (default: true)
@@ -66,6 +80,7 @@ Options:
66
80
  --auto-refresh Enable/disable background auto-refresh (default: true)
67
81
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
68
82
  --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60, 0 = disabled)
83
+ --refresh-cooldown <seconds> Minimum time between refreshes per agent (default: 30, 0 = disabled)
69
84
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
70
85
  --keepalive Enable/disable session keepalive (default: true)
71
86
  --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300, 0 = disabled)
@@ -88,9 +103,11 @@ Environment variables:
88
103
  AGENT_TANK_DOCKER Enable/disable Docker bridge bind ("1"/"true" or "0"/"false")
89
104
  AGENT_TANK_FRESH_PROCESS Use fresh process per refresh ("1" or "true")
90
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
91
107
  AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
92
108
  AGENT_TANK_AUTO_REFRESH_MODE Refresh mode: none, interval, activity
93
109
  AGENT_TANK_AUTO_REFRESH_INTERVAL Auto-refresh interval in seconds
110
+ AGENT_TANK_REFRESH_COOLDOWN Minimum time between refreshes per agent in seconds
94
111
  AGENT_TANK_ACTIVITY_DEBOUNCE Activity debounce interval in milliseconds
95
112
  AGENT_TANK_KEEPALIVE Enable/disable session keepalive ("1" or "true" / "0" or "false")
96
113
  AGENT_TANK_KEEPALIVE_INTERVAL Session keepalive interval in seconds
@@ -102,6 +119,7 @@ Examples:
102
119
  agent-tank --port 8080 # Use custom port
103
120
  agent-tank --host 0.0.0.0 # Expose on all interfaces
104
121
  agent-tank --no-docker # Bind localhost only when host is omitted
122
+ agent-tank --gemini-mode direct # Prefer direct Gemini quota API calls
105
123
  agent-tank --auth-user admin --auth-pass secret # Enable basic auth
106
124
  agent-tank --auth-token mykey # Enable API key auth
107
125
  agent-tank -c ./config.json # Use config file
@@ -109,6 +127,7 @@ Examples:
109
127
  agent-tank --auto-refresh-mode activity # Use activity-based polling (default)
110
128
  agent-tank --activity-debounce 10000 # Wait 10 seconds after activity before refresh
111
129
  agent-tank --auto-refresh-interval 30 # Refresh every 30 seconds
130
+ agent-tank --refresh-cooldown 45 # Limit each agent to one refresh every 45 seconds
112
131
  agent-tank --no-auto-refresh # Disable background auto-refresh
113
132
  agent-tank --keepalive-interval 600 # Send keepalive every 10 minutes
114
133
  agent-tank --no-keepalive # Disable session keepalive
@@ -184,6 +203,9 @@ async function main() {
184
203
  config.claudeApi ||
185
204
  claudeApiEnv === '1' || claudeApiEnv === 'true';
186
205
 
206
+ const geminiModeEnv = process.env.AGENT_TANK_GEMINI_MODE;
207
+ const geminiMode = geminiModeEnv || values['gemini-mode'] || config.geminiMode || 'fallback';
208
+
187
209
  // Auto-refresh configuration (env > CLI > config file)
188
210
  const autoRefreshEnv = process.env.AGENT_TANK_AUTO_REFRESH;
189
211
  let autoRefreshEnabled = values['auto-refresh'];
@@ -210,6 +232,16 @@ async function main() {
210
232
  autoRefreshMode = config.autoRefresh.mode;
211
233
  }
212
234
 
235
+ const refreshCooldownEnv = process.env.AGENT_TANK_REFRESH_COOLDOWN;
236
+ let refreshCooldown = parseInt(values['refresh-cooldown'], 10);
237
+ if (refreshCooldownEnv !== undefined) {
238
+ refreshCooldown = parseInt(refreshCooldownEnv, 10);
239
+ } else if (config.refreshCooldown !== undefined) {
240
+ refreshCooldown = config.refreshCooldown;
241
+ } else if (config.autoRefresh?.refreshCooldown !== undefined) {
242
+ refreshCooldown = config.autoRefresh.refreshCooldown;
243
+ }
244
+
213
245
  // Activity debounce configuration (env > CLI > config file)
214
246
  const activityDebounceEnv = process.env.AGENT_TANK_ACTIVITY_DEBOUNCE;
215
247
  let activityDebounce = parseInt(values['activity-debounce'], 10);
@@ -266,9 +298,11 @@ async function main() {
266
298
  auth,
267
299
  freshProcess,
268
300
  claudeApi,
301
+ geminiMode,
269
302
  autoRefreshEnabled: onceMode ? false : autoRefreshEnabled, // Disable auto-refresh in one-shot mode
270
303
  autoRefreshInterval,
271
304
  autoRefreshMode: onceMode ? 'none' : autoRefreshMode, // Disable auto-refresh in one-shot mode
305
+ refreshCooldown,
272
306
  activityDebounce,
273
307
  keepaliveEnabled: onceMode ? false : keepaliveEnabled, // Disable keepalive in one-shot mode
274
308
  keepaliveInterval,
@@ -296,21 +330,28 @@ async function main() {
296
330
  await watcher.start();
297
331
  const status = watcher.getStatus();
298
332
  if (jsonMode) {
299
- // Restore console.log for JSON output
300
- console.log = originalLog;
301
- console.log(JSON.stringify(status, null, 2));
302
- // Suppress console.log again before stopping to avoid extra shutdown output
303
- 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;
304
341
  } else {
342
+ let output = '';
305
343
  for (const [name, agentStatus] of Object.entries(status)) {
306
- originalLog(`\n=== ${name.toUpperCase()} ===`);
307
- originalLog(JSON.stringify(agentStatus, null, 2));
344
+ output += `\n=== ${name.toUpperCase()} ===\n`;
345
+ output += `${JSON.stringify(agentStatus, null, 2)}\n`;
308
346
  }
347
+ writeAndExit(process.stdout, output, 0, {
348
+ cleanup: cleanupShutdownHandlers,
349
+ watcher,
350
+ jsonMode,
351
+ originalLog,
352
+ });
353
+ return;
309
354
  }
310
- cleanupShutdownHandlers();
311
- watcher.stop();
312
- process.exitCode = 0;
313
- return;
314
355
  }
315
356
 
316
357
  await watcher.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.4",
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();
@@ -329,11 +338,15 @@ class BaseAgent {
329
338
 
330
339
  // Process is already at prompt, send commands immediately
331
340
  logger.agent(this.name, 'Sending commands to persistent process...');
332
- this.sendCommands(this.shell, this.output);
341
+ this.sendCommands(this.shell, existingOutput);
333
342
  });
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
  }
@@ -23,32 +69,36 @@ class GeminiAgent extends BaseAgent {
23
69
  };
24
70
  }
25
71
 
72
+ _hasReadyPrompt(output) {
73
+ const clean = this.stripAnsi(output);
74
+ return clean.includes('Type your message')
75
+ || clean.includes('gemini>')
76
+ || /Ready\s*\(/.test(clean);
77
+ }
78
+
26
79
  isReadyForCommands(output) {
27
80
  // Don't consider ready if authentication is in progress
28
81
  if (this._isAuthenticating(output)) {
29
82
  return false;
30
83
  }
31
- // v0.24: "Type your message" prompt
32
- // v0.35+: "Ready" in terminal title + visible prompt area (no "Type your message")
33
- // Also check for terminal title sequence containing "Ready"
34
- return output.includes('Type your message')
35
- || output.includes('gemini>')
36
- || /Ready\s*\(/.test(output);
84
+ return this._hasReadyPrompt(output);
37
85
  }
38
86
 
39
87
  _isAuthenticating(output) {
40
88
  // Detect OAuth/authentication screens where Escape would cancel the flow
41
89
  const clean = this.stripAnsi(output);
90
+ const hasAuthFlow = /waiting for authentication/i.test(clean)
91
+ || /press esc.*to cancel/i.test(clean)
92
+ || /authenticating\.\.\./i.test(clean);
42
93
 
43
- // If we see the ready prompt, auth is complete (even if auth text still in buffer)
44
- if (clean.includes('Type your message') || clean.includes('? for shortcuts')) {
94
+ if (!hasAuthFlow) {
45
95
  return false;
46
96
  }
47
97
 
48
- // Only consider authenticating if we see auth-specific text
49
- return /waiting for authentication/i.test(clean)
50
- || /press esc.*to cancel/i.test(clean)
51
- || /authenticating\.\.\./i.test(clean);
98
+ // Newer Gemini builds can render footer hints like "? for shortcuts"
99
+ // before the actual input prompt is available. Treat auth as complete
100
+ // only when the real prompt has returned.
101
+ return !this._hasReadyPrompt(clean);
52
102
  }
53
103
 
54
104
  detectAuthenticationState(output) {
@@ -159,12 +209,10 @@ class GeminiAgent extends BaseAgent {
159
209
  return;
160
210
  }
161
211
  logger.agent(this.name, 'Sending /stats command...');
162
- // v0.35+: typing /stats triggers autocomplete dropdown.
163
- // Escape clears any pending state, then send command, Escape again to dismiss autocomplete
164
- setTimeout(() => shell.write('\x1b'), 50);
165
- setTimeout(() => shell.write('/stats'), 500);
166
- setTimeout(() => shell.write('\x1b'), 1000);
167
- setTimeout(() => shell.write('\r'), 1500);
212
+ // Newer Gemini builds use Esc for "rewind", which can derail the command flow.
213
+ // Submit /stats directly once the prompt is ready.
214
+ setTimeout(() => shell.write('/stats'), 200);
215
+ setTimeout(() => shell.write('\r'), 600);
168
216
  }
169
217
 
170
218
  // Calculate pace data for a model entry (Gemini uses 24h sessions)
@@ -386,6 +434,11 @@ class GeminiAgent extends BaseAgent {
386
434
  * @returns {Promise<boolean>} True if keepalive succeeded
387
435
  */
388
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
+
389
442
  if (this.freshProcess) {
390
443
  console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
391
444
  return true;
@@ -230,12 +230,11 @@ class AutoRefreshManager {
230
230
  console.log(`Backend auto-refresh [${globalAgents.join(', ')}]: every ${globalInterval}s (interval mode)`);
231
231
  this.autoRefreshTimer = setInterval(async () => {
232
232
  console.log(`[Auto-refresh] Refreshing ${globalAgents.join(', ')}...`);
233
- const promises = globalAgents.map(name => {
234
- const agent = this.agents.get(name);
235
- return agent ? agent.refresh().catch(err =>
233
+ const promises = globalAgents.map(name =>
234
+ this.onRefreshAgent(name).catch(err =>
236
235
  console.error(`Error refreshing ${name}:`, err.message)
237
- ) : Promise.resolve();
238
- });
236
+ )
237
+ );
239
238
  await Promise.all(promises);
240
239
  this.lastRefreshedAt = new Date().toISOString();
241
240
  }, globalInterval * 1000);
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const path = require('node:path');
2
2
  const os = require('node:os');
3
+ const { execFileSync } = require('node:child_process');
3
4
  const { ClaudeAgent } = require('./agents/claude.js');
4
5
  const { GeminiAgent } = require('./agents/gemini.js');
5
6
  const { CodexAgent } = require('./agents/codex.js');
@@ -49,7 +50,9 @@ function isPrivateIpv4(address) {
49
50
  /^172\.(1[6-9]|2\d|3[0-1])\./.test(address);
50
51
  }
51
52
 
52
- function detectDockerHost(networkInterfaces = os.networkInterfaces()) {
53
+ function detectDockerHostsFromInterfaces(networkInterfaces = os.networkInterfaces()) {
54
+ const hosts = [];
55
+
53
56
  for (const [name, addresses] of Object.entries(networkInterfaces)) {
54
57
  if (!DOCKER_INTERFACE_PATTERNS.some(pattern => pattern.test(name))) {
55
58
  continue;
@@ -57,12 +60,55 @@ function detectDockerHost(networkInterfaces = os.networkInterfaces()) {
57
60
 
58
61
  for (const info of addresses || []) {
59
62
  if (info && info.family === 'IPv4' && !info.internal && isPrivateIpv4(info.address)) {
60
- return info.address;
63
+ if (!hosts.includes(info.address)) {
64
+ hosts.push(info.address);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ return hosts.length > 0 ? hosts : null;
71
+ }
72
+
73
+ function detectDockerHosts({
74
+ exec = execFileSync,
75
+ networkInterfaces = os.networkInterfaces(),
76
+ } = {}) {
77
+ try {
78
+ const listOutput = exec('docker', ['network', 'ls', '--format', '{{json .}}'], {
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ });
82
+ const networks = listOutput
83
+ .split('\n')
84
+ .map(line => line.trim())
85
+ .filter(Boolean)
86
+ .map(line => JSON.parse(line))
87
+ .filter(network => network && network.Driver === 'bridge');
88
+
89
+ const hosts = [];
90
+ for (const network of networks) {
91
+ const inspectOutput = exec('docker', ['network', 'inspect', network.ID, '--format', '{{json .IPAM.Config}}'], {
92
+ encoding: 'utf8',
93
+ stdio: ['ignore', 'pipe', 'ignore'],
94
+ }).trim();
95
+
96
+ const ipamConfigs = inspectOutput ? JSON.parse(inspectOutput) : [];
97
+ for (const config of ipamConfigs || []) {
98
+ if (config && config.Gateway && isPrivateIpv4(config.Gateway) && !hosts.includes(config.Gateway)) {
99
+ hosts.push(config.Gateway);
100
+ }
61
101
  }
62
102
  }
103
+
104
+ if (hosts.length > 0) {
105
+ return hosts;
106
+ }
107
+ } catch (_err) {
108
+ // Fall back to interface detection when Docker is unavailable.
63
109
  }
64
110
 
65
- return null;
111
+ return detectDockerHostsFromInterfaces(networkInterfaces);
66
112
  }
67
113
 
68
114
  class AgentTank {
@@ -75,6 +121,7 @@ class AgentTank {
75
121
  this.requestedAgents = options.agents || null;
76
122
  this.freshProcess = options.freshProcess || false;
77
123
  this.claudeApi = options.claudeApi || false; // Use direct Anthropic API for Claude
124
+ this.geminiMode = options.geminiMode || 'fallback';
78
125
  this.auth = options.auth || {};
79
126
  this.agents = new Map();
80
127
  this.server = null;
@@ -83,6 +130,9 @@ class AgentTank {
83
130
  this.publicStatus = {}; // Public API status from upstream providers
84
131
  this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
85
132
  this.lastRefreshedAt = null;
133
+ this.refreshCooldown = Math.max(0, options.refreshCooldown ?? 30);
134
+ this._agentRefreshStartedAt = new Map();
135
+ this._agentRefreshPromises = new Map();
86
136
  this.stopping = false;
87
137
 
88
138
  // Auto-refresh configuration (backend periodic refresh)
@@ -278,7 +328,7 @@ class AgentTank {
278
328
  case 'claude':
279
329
  return new ClaudeAgent({ useApi: this.claudeApi });
280
330
  case 'gemini':
281
- return new GeminiAgent();
331
+ return new GeminiAgent({ mode: this.geminiMode });
282
332
  case 'codex':
283
333
  return new CodexAgent();
284
334
  default:
@@ -299,10 +349,10 @@ class AgentTank {
299
349
  const [, publicStatusResult] = await Promise.all([
300
350
  // Agent PTY refreshes
301
351
  Promise.all(
302
- Array.from(this.agents.values()).map(agent =>
303
- agent.refresh().catch(err => {
352
+ Array.from(this.agents.keys()).map(name =>
353
+ this._refreshAgentWithCooldown(name).catch(err => {
304
354
  if (!this.stopping && err.message !== 'Agent stopping') {
305
- logger.error(`Error refreshing ${agent.name}:`, err.message);
355
+ logger.error(`Error refreshing ${name}:`, err.message);
306
356
  }
307
357
  })
308
358
  )
@@ -347,7 +397,7 @@ class AgentTank {
347
397
  if (!agent) {
348
398
  throw new Error(`Agent not found: ${name}`);
349
399
  }
350
- await agent.refresh();
400
+ await this._refreshAgentWithCooldown(name);
351
401
 
352
402
  // Record snapshot and attach pace evaluation for single agent refresh
353
403
  if (agent.usage) {
@@ -356,6 +406,47 @@ class AgentTank {
356
406
  }
357
407
  }
358
408
 
409
+ _getRefreshCooldownRemainingMs(name, now = Date.now()) {
410
+ if (this.refreshCooldown <= 0) {
411
+ return 0;
412
+ }
413
+
414
+ const lastStartedAt = this._agentRefreshStartedAt.get(name);
415
+ if (!lastStartedAt) {
416
+ return 0;
417
+ }
418
+
419
+ return Math.max(0, (this.refreshCooldown * 1000) - (now - lastStartedAt));
420
+ }
421
+
422
+ async _refreshAgentWithCooldown(name) {
423
+ const agent = this.agents.get(name);
424
+ if (!agent) {
425
+ throw new Error(`Agent not found: ${name}`);
426
+ }
427
+
428
+ const activeRefresh = this._agentRefreshPromises.get(name);
429
+ if (activeRefresh) {
430
+ logger.agent(name, 'Refresh already in progress, reusing existing refresh');
431
+ return activeRefresh;
432
+ }
433
+
434
+ const remainingMs = this._getRefreshCooldownRemainingMs(name);
435
+ if (remainingMs > 0) {
436
+ logger.agent(name, `Refresh cooldown active, skipping (${Math.ceil(remainingMs / 1000)}s remaining)`);
437
+ return;
438
+ }
439
+
440
+ this._agentRefreshStartedAt.set(name, Date.now());
441
+ const refreshPromise = agent.refresh().finally(() => {
442
+ if (this._agentRefreshPromises.get(name) === refreshPromise) {
443
+ this._agentRefreshPromises.delete(name);
444
+ }
445
+ });
446
+ this._agentRefreshPromises.set(name, refreshPromise);
447
+ return refreshPromise;
448
+ }
449
+
359
450
  /** Get usage history statistics. @returns {Object} */
360
451
  getHistoryStats() { return this.historyStore.getStats(); }
361
452
 
@@ -447,16 +538,22 @@ class AgentTank {
447
538
  });
448
539
  }
449
540
 
450
- _resolveListenHosts(detectHost = detectDockerHost) {
541
+ _resolveListenHosts(detectHost = detectDockerHosts) {
451
542
  if (this.explicitHost) {
452
543
  return [this.host];
453
544
  }
454
545
 
455
546
  const hosts = ['127.0.0.1'];
456
547
  if (this.dockerAccess) {
457
- const dockerHost = detectHost();
458
- if (dockerHost && dockerHost !== '127.0.0.1') {
459
- hosts.push(dockerHost);
548
+ const dockerHosts = detectHost();
549
+ if (dockerHosts) {
550
+ // Handle both single IP (legacy) and array of IPs
551
+ const hostList = Array.isArray(dockerHosts) ? dockerHosts : [dockerHosts];
552
+ for (const h of hostList) {
553
+ if (h && h !== '127.0.0.1' && !hosts.includes(h)) {
554
+ hosts.push(h);
555
+ }
556
+ }
460
557
  }
461
558
  }
462
559
 
@@ -500,8 +597,15 @@ class AgentTank {
500
597
 
501
598
  /** Get the auto-refresh configuration. @returns {Object} */
502
599
  getAutoRefreshConfig() {
503
- return { mode: this.autoRefresh.mode, enabled: this.autoRefresh.enabled, interval: this.autoRefresh.interval, activityDebounce: this.autoRefresh.activityDebounce, lastRefreshedAt: this.autoRefreshManager ? this.autoRefreshManager.getLastRefreshedAt() : null };
600
+ return {
601
+ mode: this.autoRefresh.mode,
602
+ enabled: this.autoRefresh.enabled,
603
+ interval: this.autoRefresh.interval,
604
+ activityDebounce: this.autoRefresh.activityDebounce,
605
+ refreshCooldown: this.refreshCooldown,
606
+ lastRefreshedAt: this.autoRefreshManager ? this.autoRefreshManager.getLastRefreshedAt() : null
607
+ };
504
608
  }
505
609
  }
506
610
 
507
- module.exports = { AgentTank, AUTO_REFRESH_MODES, detectDockerHost };
611
+ module.exports = { AgentTank, AUTO_REFRESH_MODES, detectDockerHosts, detectDockerHostsFromInterfaces };
package/src/server.js CHANGED
@@ -52,6 +52,7 @@ function createRequestHandler(tank) {
52
52
  autoRefresh: {
53
53
  enabled: tank.autoRefresh.enabled && tank.autoRefresh.interval > 0,
54
54
  interval: tank.autoRefresh.interval, // in seconds
55
+ refreshCooldown: tank.refreshCooldown,
55
56
  },
56
57
  lastRefreshedAt: tank.lastRefreshedAt,
57
58
  };