agent-tank 0.9.4 → 0.9.5

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
 
@@ -97,6 +97,9 @@ agent-tank --once --json
97
97
 
98
98
  # Show the installed Agent Tank version
99
99
  agent-tank --version
100
+
101
+ # Require at least 60 seconds between refreshes for the same agent
102
+ agent-tank --refresh-cooldown 60
100
103
  ```
101
104
 
102
105
  ## What You Get
@@ -165,6 +168,16 @@ agent-tank --no-docker
165
168
  agent-tank --auto-refresh-mode none
166
169
  ```
167
170
 
171
+ ### Slow Down Agent Refreshes
172
+
173
+ 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.
174
+
175
+ ```bash
176
+ agent-tank --refresh-cooldown 60
177
+ ```
178
+
179
+ Set it to `0` to disable the cooldown entirely.
180
+
168
181
  ## Command Line Options
169
182
 
170
183
  ```text
@@ -186,6 +199,7 @@ Options:
186
199
  --auto-refresh Enable/disable background auto-refresh (default: true)
187
200
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
188
201
  --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60)
202
+ --refresh-cooldown <seconds> Minimum time between refreshes per agent (default: 30, 0 = disabled)
189
203
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
190
204
  --keepalive Enable/disable session keepalive (default: true)
191
205
  --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300)
@@ -213,6 +227,7 @@ Environment variables override CLI flags and config file values.
213
227
  | `AGENT_TANK_AUTO_REFRESH` | Enable/disable auto-refresh |
214
228
  | `AGENT_TANK_AUTO_REFRESH_MODE` | `none`, `interval`, or `activity` |
215
229
  | `AGENT_TANK_AUTO_REFRESH_INTERVAL` | Auto-refresh interval in seconds |
230
+ | `AGENT_TANK_REFRESH_COOLDOWN` | Minimum time between refreshes per agent in seconds |
216
231
  | `AGENT_TANK_ACTIVITY_DEBOUNCE` | Activity debounce interval in milliseconds |
217
232
  | `AGENT_TANK_KEEPALIVE` | Enable/disable keepalive |
218
233
  | `AGENT_TANK_KEEPALIVE_INTERVAL` | Keepalive interval in seconds |
@@ -228,10 +243,12 @@ Environment variables override CLI flags and config file values.
228
243
  "port": 8080,
229
244
  "dockerAccess": true,
230
245
  "claudeApi": false,
246
+ "refreshCooldown": 30,
231
247
  "autoRefresh": {
232
248
  "mode": "activity",
233
249
  "interval": 60,
234
- "activityDebounce": 5000
250
+ "activityDebounce": 5000,
251
+ "refreshCooldown": 30
235
252
  },
236
253
  "keepalive": {
237
254
  "enabled": true,
package/bin/agent-tank.js CHANGED
@@ -25,6 +25,7 @@ const options = {
25
25
  'auto-refresh': { type: 'boolean', default: true },
26
26
  'auto-refresh-interval': { type: 'string', default: '60' },
27
27
  'auto-refresh-mode': { type: 'string', default: 'activity' },
28
+ 'refresh-cooldown': { type: 'string', default: '30' },
28
29
  'activity-debounce': { type: 'string', default: '5000' },
29
30
  'history-retention-days': { type: 'string', default: '14' },
30
31
  'keepalive': { type: 'boolean', default: true },
@@ -66,6 +67,7 @@ Options:
66
67
  --auto-refresh Enable/disable background auto-refresh (default: true)
67
68
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
68
69
  --auto-refresh-interval <seconds> Auto-refresh interval in seconds (default: 60, 0 = disabled)
70
+ --refresh-cooldown <seconds> Minimum time between refreshes per agent (default: 30, 0 = disabled)
69
71
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
70
72
  --keepalive Enable/disable session keepalive (default: true)
71
73
  --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300, 0 = disabled)
@@ -91,6 +93,7 @@ Environment variables:
91
93
  AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
92
94
  AGENT_TANK_AUTO_REFRESH_MODE Refresh mode: none, interval, activity
93
95
  AGENT_TANK_AUTO_REFRESH_INTERVAL Auto-refresh interval in seconds
96
+ AGENT_TANK_REFRESH_COOLDOWN Minimum time between refreshes per agent in seconds
94
97
  AGENT_TANK_ACTIVITY_DEBOUNCE Activity debounce interval in milliseconds
95
98
  AGENT_TANK_KEEPALIVE Enable/disable session keepalive ("1" or "true" / "0" or "false")
96
99
  AGENT_TANK_KEEPALIVE_INTERVAL Session keepalive interval in seconds
@@ -109,6 +112,7 @@ Examples:
109
112
  agent-tank --auto-refresh-mode activity # Use activity-based polling (default)
110
113
  agent-tank --activity-debounce 10000 # Wait 10 seconds after activity before refresh
111
114
  agent-tank --auto-refresh-interval 30 # Refresh every 30 seconds
115
+ agent-tank --refresh-cooldown 45 # Limit each agent to one refresh every 45 seconds
112
116
  agent-tank --no-auto-refresh # Disable background auto-refresh
113
117
  agent-tank --keepalive-interval 600 # Send keepalive every 10 minutes
114
118
  agent-tank --no-keepalive # Disable session keepalive
@@ -210,6 +214,16 @@ async function main() {
210
214
  autoRefreshMode = config.autoRefresh.mode;
211
215
  }
212
216
 
217
+ const refreshCooldownEnv = process.env.AGENT_TANK_REFRESH_COOLDOWN;
218
+ let refreshCooldown = parseInt(values['refresh-cooldown'], 10);
219
+ if (refreshCooldownEnv !== undefined) {
220
+ refreshCooldown = parseInt(refreshCooldownEnv, 10);
221
+ } else if (config.refreshCooldown !== undefined) {
222
+ refreshCooldown = config.refreshCooldown;
223
+ } else if (config.autoRefresh?.refreshCooldown !== undefined) {
224
+ refreshCooldown = config.autoRefresh.refreshCooldown;
225
+ }
226
+
213
227
  // Activity debounce configuration (env > CLI > config file)
214
228
  const activityDebounceEnv = process.env.AGENT_TANK_ACTIVITY_DEBOUNCE;
215
229
  let activityDebounce = parseInt(values['activity-debounce'], 10);
@@ -269,6 +283,7 @@ async function main() {
269
283
  autoRefreshEnabled: onceMode ? false : autoRefreshEnabled, // Disable auto-refresh in one-shot mode
270
284
  autoRefreshInterval,
271
285
  autoRefreshMode: onceMode ? 'none' : autoRefreshMode, // Disable auto-refresh in one-shot mode
286
+ refreshCooldown,
272
287
  activityDebounce,
273
288
  keepaliveEnabled: onceMode ? false : keepaliveEnabled, // Disable keepalive in one-shot mode
274
289
  keepaliveInterval,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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": {
@@ -329,7 +329,7 @@ class BaseAgent {
329
329
 
330
330
  // Process is already at prompt, send commands immediately
331
331
  logger.agent(this.name, 'Sending commands to persistent process...');
332
- this.sendCommands(this.shell, this.output);
332
+ this.sendCommands(this.shell, existingOutput);
333
333
  });
334
334
  }
335
335
 
@@ -23,32 +23,36 @@ class GeminiAgent extends BaseAgent {
23
23
  };
24
24
  }
25
25
 
26
+ _hasReadyPrompt(output) {
27
+ const clean = this.stripAnsi(output);
28
+ return clean.includes('Type your message')
29
+ || clean.includes('gemini>')
30
+ || /Ready\s*\(/.test(clean);
31
+ }
32
+
26
33
  isReadyForCommands(output) {
27
34
  // Don't consider ready if authentication is in progress
28
35
  if (this._isAuthenticating(output)) {
29
36
  return false;
30
37
  }
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);
38
+ return this._hasReadyPrompt(output);
37
39
  }
38
40
 
39
41
  _isAuthenticating(output) {
40
42
  // Detect OAuth/authentication screens where Escape would cancel the flow
41
43
  const clean = this.stripAnsi(output);
44
+ const hasAuthFlow = /waiting for authentication/i.test(clean)
45
+ || /press esc.*to cancel/i.test(clean)
46
+ || /authenticating\.\.\./i.test(clean);
42
47
 
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')) {
48
+ if (!hasAuthFlow) {
45
49
  return false;
46
50
  }
47
51
 
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);
52
+ // Newer Gemini builds can render footer hints like "? for shortcuts"
53
+ // before the actual input prompt is available. Treat auth as complete
54
+ // only when the real prompt has returned.
55
+ return !this._hasReadyPrompt(clean);
52
56
  }
53
57
 
54
58
  detectAuthenticationState(output) {
@@ -159,12 +163,10 @@ class GeminiAgent extends BaseAgent {
159
163
  return;
160
164
  }
161
165
  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);
166
+ // Newer Gemini builds use Esc for "rewind", which can derail the command flow.
167
+ // Submit /stats directly once the prompt is ready.
168
+ setTimeout(() => shell.write('/stats'), 200);
169
+ setTimeout(() => shell.write('\r'), 600);
168
170
  }
169
171
 
170
172
  // Calculate pace data for a model entry (Gemini uses 24h sessions)
@@ -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 {
@@ -83,6 +129,9 @@ class AgentTank {
83
129
  this.publicStatus = {}; // Public API status from upstream providers
84
130
  this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
85
131
  this.lastRefreshedAt = null;
132
+ this.refreshCooldown = Math.max(0, options.refreshCooldown ?? 30);
133
+ this._agentRefreshStartedAt = new Map();
134
+ this._agentRefreshPromises = new Map();
86
135
  this.stopping = false;
87
136
 
88
137
  // Auto-refresh configuration (backend periodic refresh)
@@ -299,10 +348,10 @@ class AgentTank {
299
348
  const [, publicStatusResult] = await Promise.all([
300
349
  // Agent PTY refreshes
301
350
  Promise.all(
302
- Array.from(this.agents.values()).map(agent =>
303
- agent.refresh().catch(err => {
351
+ Array.from(this.agents.keys()).map(name =>
352
+ this._refreshAgentWithCooldown(name).catch(err => {
304
353
  if (!this.stopping && err.message !== 'Agent stopping') {
305
- logger.error(`Error refreshing ${agent.name}:`, err.message);
354
+ logger.error(`Error refreshing ${name}:`, err.message);
306
355
  }
307
356
  })
308
357
  )
@@ -347,7 +396,7 @@ class AgentTank {
347
396
  if (!agent) {
348
397
  throw new Error(`Agent not found: ${name}`);
349
398
  }
350
- await agent.refresh();
399
+ await this._refreshAgentWithCooldown(name);
351
400
 
352
401
  // Record snapshot and attach pace evaluation for single agent refresh
353
402
  if (agent.usage) {
@@ -356,6 +405,47 @@ class AgentTank {
356
405
  }
357
406
  }
358
407
 
408
+ _getRefreshCooldownRemainingMs(name, now = Date.now()) {
409
+ if (this.refreshCooldown <= 0) {
410
+ return 0;
411
+ }
412
+
413
+ const lastStartedAt = this._agentRefreshStartedAt.get(name);
414
+ if (!lastStartedAt) {
415
+ return 0;
416
+ }
417
+
418
+ return Math.max(0, (this.refreshCooldown * 1000) - (now - lastStartedAt));
419
+ }
420
+
421
+ async _refreshAgentWithCooldown(name) {
422
+ const agent = this.agents.get(name);
423
+ if (!agent) {
424
+ throw new Error(`Agent not found: ${name}`);
425
+ }
426
+
427
+ const activeRefresh = this._agentRefreshPromises.get(name);
428
+ if (activeRefresh) {
429
+ logger.agent(name, 'Refresh already in progress, reusing existing refresh');
430
+ return activeRefresh;
431
+ }
432
+
433
+ const remainingMs = this._getRefreshCooldownRemainingMs(name);
434
+ if (remainingMs > 0) {
435
+ logger.agent(name, `Refresh cooldown active, skipping (${Math.ceil(remainingMs / 1000)}s remaining)`);
436
+ return;
437
+ }
438
+
439
+ this._agentRefreshStartedAt.set(name, Date.now());
440
+ const refreshPromise = agent.refresh().finally(() => {
441
+ if (this._agentRefreshPromises.get(name) === refreshPromise) {
442
+ this._agentRefreshPromises.delete(name);
443
+ }
444
+ });
445
+ this._agentRefreshPromises.set(name, refreshPromise);
446
+ return refreshPromise;
447
+ }
448
+
359
449
  /** Get usage history statistics. @returns {Object} */
360
450
  getHistoryStats() { return this.historyStore.getStats(); }
361
451
 
@@ -447,16 +537,22 @@ class AgentTank {
447
537
  });
448
538
  }
449
539
 
450
- _resolveListenHosts(detectHost = detectDockerHost) {
540
+ _resolveListenHosts(detectHost = detectDockerHosts) {
451
541
  if (this.explicitHost) {
452
542
  return [this.host];
453
543
  }
454
544
 
455
545
  const hosts = ['127.0.0.1'];
456
546
  if (this.dockerAccess) {
457
- const dockerHost = detectHost();
458
- if (dockerHost && dockerHost !== '127.0.0.1') {
459
- hosts.push(dockerHost);
547
+ const dockerHosts = detectHost();
548
+ if (dockerHosts) {
549
+ // Handle both single IP (legacy) and array of IPs
550
+ const hostList = Array.isArray(dockerHosts) ? dockerHosts : [dockerHosts];
551
+ for (const h of hostList) {
552
+ if (h && h !== '127.0.0.1' && !hosts.includes(h)) {
553
+ hosts.push(h);
554
+ }
555
+ }
460
556
  }
461
557
  }
462
558
 
@@ -500,8 +596,15 @@ class AgentTank {
500
596
 
501
597
  /** Get the auto-refresh configuration. @returns {Object} */
502
598
  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 };
599
+ return {
600
+ mode: this.autoRefresh.mode,
601
+ enabled: this.autoRefresh.enabled,
602
+ interval: this.autoRefresh.interval,
603
+ activityDebounce: this.autoRefresh.activityDebounce,
604
+ refreshCooldown: this.refreshCooldown,
605
+ lastRefreshedAt: this.autoRefreshManager ? this.autoRefreshManager.getLastRefreshedAt() : null
606
+ };
504
607
  }
505
608
  }
506
609
 
507
- module.exports = { AgentTank, AUTO_REFRESH_MODES, detectDockerHost };
610
+ 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
  };