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 +47 -2
- package/bin/agent-tank.js +52 -11
- package/package.json +1 -1
- package/src/agents/base.js +18 -1
- package/src/agents/gemini-direct.js +234 -0
- package/src/agents/gemini.js +73 -20
- package/src/auto-refresh-manager.js +4 -5
- package/src/index.js +118 -14
- package/src/server.js +1 -0
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
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
package/src/agents/base.js
CHANGED
|
@@ -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,
|
|
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
|
+
};
|
package/src/agents/gemini.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
if (clean.includes('Type your message') || clean.includes('? for shortcuts')) {
|
|
94
|
+
if (!hasAuthFlow) {
|
|
45
95
|
return false;
|
|
46
96
|
}
|
|
47
97
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
setTimeout(() => shell.write('
|
|
165
|
-
setTimeout(() => shell.write('
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
303
|
-
|
|
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 ${
|
|
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
|
|
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 =
|
|
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
|
|
458
|
-
if (
|
|
459
|
-
|
|
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 {
|
|
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,
|
|
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
|
};
|