agent-tank 0.9.3 → 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/bin/agent-tank.js CHANGED
@@ -13,6 +13,7 @@ const options = {
13
13
  'claude-api': { type: 'boolean', default: false },
14
14
  port: { type: 'string', default: '3456' },
15
15
  host: { type: 'string' },
16
+ docker: { type: 'boolean', default: true },
16
17
  'auth-user': { type: 'string' },
17
18
  'auth-pass': { type: 'string' },
18
19
  'auth-token': { type: 'string' },
@@ -24,6 +25,7 @@ const options = {
24
25
  'auto-refresh': { type: 'boolean', default: true },
25
26
  'auto-refresh-interval': { type: 'string', default: '60' },
26
27
  'auto-refresh-mode': { type: 'string', default: 'activity' },
28
+ 'refresh-cooldown': { type: 'string', default: '30' },
27
29
  'activity-debounce': { type: 'string', default: '5000' },
28
30
  'history-retention-days': { type: 'string', default: '14' },
29
31
  'keepalive': { type: 'boolean', default: true },
@@ -53,7 +55,8 @@ Options:
53
55
  --codex Enable Codex monitoring
54
56
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
55
57
  --port <port> HTTP server port (default: 3456)
56
- --host <host> Bind address (default: 127.0.0.1)
58
+ --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
59
+ --docker Enable Docker bridge bind when --host is omitted (default: true)
57
60
  --auth-user <user> HTTP Basic Auth username
58
61
  --auth-pass <pass> HTTP Basic Auth password
59
62
  --auth-token <token> API key for Bearer token auth
@@ -64,6 +67,7 @@ Options:
64
67
  --auto-refresh Enable/disable background auto-refresh (default: true)
65
68
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
66
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)
67
71
  --activity-debounce <ms> Activity debounce interval in milliseconds (default: 5000)
68
72
  --keepalive Enable/disable session keepalive (default: true)
69
73
  --keepalive-interval <seconds> Session keepalive interval in seconds (default: 300, 0 = disabled)
@@ -83,11 +87,13 @@ Environment variables:
83
87
  AGENT_TANK_PASS Basic auth password (overrides --auth-pass)
84
88
  AGENT_TANK_TOKEN API key (overrides --auth-token)
85
89
  AGENT_TANK_HOST Bind address (overrides --host)
90
+ AGENT_TANK_DOCKER Enable/disable Docker bridge bind ("1"/"true" or "0"/"false")
86
91
  AGENT_TANK_FRESH_PROCESS Use fresh process per refresh ("1" or "true")
87
92
  AGENT_TANK_CLAUDE_API Use direct Anthropic API for Claude usage ("1" or "true")
88
93
  AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
89
94
  AGENT_TANK_AUTO_REFRESH_MODE Refresh mode: none, interval, activity
90
95
  AGENT_TANK_AUTO_REFRESH_INTERVAL Auto-refresh interval in seconds
96
+ AGENT_TANK_REFRESH_COOLDOWN Minimum time between refreshes per agent in seconds
91
97
  AGENT_TANK_ACTIVITY_DEBOUNCE Activity debounce interval in milliseconds
92
98
  AGENT_TANK_KEEPALIVE Enable/disable session keepalive ("1" or "true" / "0" or "false")
93
99
  AGENT_TANK_KEEPALIVE_INTERVAL Session keepalive interval in seconds
@@ -98,6 +104,7 @@ Examples:
98
104
  agent-tank --claude --gemini # Monitor specific agents
99
105
  agent-tank --port 8080 # Use custom port
100
106
  agent-tank --host 0.0.0.0 # Expose on all interfaces
107
+ agent-tank --no-docker # Bind localhost only when host is omitted
101
108
  agent-tank --auth-user admin --auth-pass secret # Enable basic auth
102
109
  agent-tank --auth-token mykey # Enable API key auth
103
110
  agent-tank -c ./config.json # Use config file
@@ -105,6 +112,7 @@ Examples:
105
112
  agent-tank --auto-refresh-mode activity # Use activity-based polling (default)
106
113
  agent-tank --activity-debounce 10000 # Wait 10 seconds after activity before refresh
107
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
108
116
  agent-tank --no-auto-refresh # Disable background auto-refresh
109
117
  agent-tank --keepalive-interval 600 # Send keepalive every 10 minutes
110
118
  agent-tank --no-keepalive # Disable session keepalive
@@ -161,6 +169,13 @@ async function main() {
161
169
  };
162
170
 
163
171
  const host = process.env.AGENT_TANK_HOST || values.host || config.host;
172
+ const dockerEnv = process.env.AGENT_TANK_DOCKER;
173
+ let dockerAccess = values.docker;
174
+ if (dockerEnv !== undefined) {
175
+ dockerAccess = dockerEnv === '1' || dockerEnv === 'true';
176
+ } else if (config.dockerAccess !== undefined) {
177
+ dockerAccess = config.dockerAccess;
178
+ }
164
179
 
165
180
  const freshProcessEnv = process.env.AGENT_TANK_FRESH_PROCESS;
166
181
  const freshProcess = values['fresh-process'] ||
@@ -199,6 +214,16 @@ async function main() {
199
214
  autoRefreshMode = config.autoRefresh.mode;
200
215
  }
201
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
+
202
227
  // Activity debounce configuration (env > CLI > config file)
203
228
  const activityDebounceEnv = process.env.AGENT_TANK_ACTIVITY_DEBOUNCE;
204
229
  let activityDebounce = parseInt(values['activity-debounce'], 10);
@@ -251,12 +276,14 @@ async function main() {
251
276
  autoDiscover: values['auto-discover'] && agents.length === 0,
252
277
  port: parseInt(values.port || config.port || '3456', 10),
253
278
  host,
279
+ dockerAccess,
254
280
  auth,
255
281
  freshProcess,
256
282
  claudeApi,
257
283
  autoRefreshEnabled: onceMode ? false : autoRefreshEnabled, // Disable auto-refresh in one-shot mode
258
284
  autoRefreshInterval,
259
285
  autoRefreshMode: onceMode ? 'none' : autoRefreshMode, // Disable auto-refresh in one-shot mode
286
+ refreshCooldown,
260
287
  activityDebounce,
261
288
  keepaliveEnabled: onceMode ? false : keepaliveEnabled, // Disable keepalive in one-shot mode
262
289
  keepaliveInterval,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.3",
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
 
@@ -204,43 +204,25 @@ class ClaudeAgent extends BaseAgent {
204
204
  const clean = this.stripAnsi(output);
205
205
  // Detect error responses (rate limiting, session errors) — treat as complete
206
206
  if (/rate.?limited|rate_limit_error|Failed to load usage|session.?expired|session.?error|invalid.?session|authentication.?error|auth.?failed|Unable to (?:load|fetch)|Error loading|could not (?:load|fetch)|not authenticated|login required|sign.?in required/i.test(clean)) return true;
207
- // Basic requirements - must have actual usage data, not just the loading screen
208
- const hasSession = clean.includes('Current session');
209
- const hasWeekly = clean.includes('Current week');
210
- const hasPercentUsed = clean.includes('% used');
211
- if (!hasSession || !hasWeekly || !hasPercentUsed) return false;
212
-
213
- // If "all models" section exists, wait for "Sonnet only" section to render
214
- const hasAllModels = /Current\s+week\s*\(?\s*all\s+models/i.test(clean);
215
- if (hasAllModels) {
216
- const hasSonnetOnly = /Current\s+week\s*\(?\s*Sonnet\s+only/i.test(clean);
217
- if (!hasSonnetOnly) {
218
- return false;
219
- }
220
- // Both sections exist, check we have at least one timezone per section
221
- const allModelsIdx = clean.search(/Current\s+week\s*\(?\s*all\s+models/i);
222
- const sonnetOnlyIdx = clean.search(/Current\s+week\s*\(?\s*Sonnet\s+only/i);
223
- const allModelsSection = clean.substring(allModelsIdx, sonnetOnlyIdx);
224
- const sonnetSection = clean.substring(sonnetOnlyIdx);
225
-
226
- const allModelsHasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(allModelsSection);
227
- const sonnetHasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(sonnetSection);
207
+ const parsed = parsePtyOutput(clean);
228
208
 
229
- return allModelsHasTimezone && sonnetHasTimezone;
230
- }
209
+ const hasSessionData = parsed.session && typeof parsed.session.percent === 'number';
210
+ const hasLegacyWeekly = parsed.weekly && typeof parsed.weekly.percent === 'number';
211
+ const hasAllModelsWeekly = parsed.weeklyAll && typeof parsed.weeklyAll.percent === 'number';
231
212
 
232
- // Fallback for legacy format (single "Current week" without model qualifiers)
233
- // Check for timezone pattern which indicates reset times are fully loaded
234
- const hasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(clean);
235
- return hasTimezone;
213
+ // Newer Claude builds often emit usable session/weekly data before the UI fully settles.
214
+ // If we can already parse session data plus either weekly format, treat the response as complete.
215
+ return hasSessionData && (hasLegacyWeekly || hasAllModelsWeekly);
236
216
  }
237
217
 
238
218
  sendCommands(shell, _output) {
239
219
  logger.agent(this.name, 'Sending /usage command...');
240
- // Escape dismisses any previous output/UI, then type command + Enter
220
+ // Claude keeps slash-command suggestions open for /usage on newer builds.
221
+ // Confirm the command selection, then submit the actual command execution.
241
222
  setTimeout(() => shell.write('\x1b'), 50);
242
223
  setTimeout(() => shell.write('/usage'), 600);
243
224
  setTimeout(() => shell.write('\r'), 1000);
225
+ setTimeout(() => shell.write('\r'), 1400);
244
226
  }
245
227
 
246
228
  // After getting /usage output, dismiss dialog so next refresh starts with clean prompt
@@ -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);
@@ -87,8 +87,8 @@ ${metricExtractors}
87
87
  if (modelContainer) {
88
88
  const resetWrapper = modelContainer.querySelector('.reset-info-wrapper');
89
89
  if (resetWrapper) {
90
- if (isZero || !resetsIn) {
91
- // Hide "Resets in" when at 0%
90
+ if (!resetsIn) {
91
+ // Hide reset info only when the backend provides no reset time
92
92
  resetWrapper.style.display = 'none';
93
93
  } else {
94
94
  resetWrapper.style.display = '';
package/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const path = require('node:path');
2
+ const os = require('node:os');
3
+ const { execFileSync } = require('node:child_process');
2
4
  const { ClaudeAgent } = require('./agents/claude.js');
3
5
  const { GeminiAgent } = require('./agents/gemini.js');
4
6
  const { CodexAgent } = require('./agents/codex.js');
@@ -22,6 +24,7 @@ const pkg = require(path.join(__dirname, '..', 'package.json'));
22
24
  * - 'activity': Activity-based polling (refreshes when log activity is detected)
23
25
  */
24
26
  const AUTO_REFRESH_MODES = ['none', 'interval', 'activity'];
27
+ const DOCKER_INTERFACE_PATTERNS = [/^docker\d*$/i, /^br-/i, /^podman\d*$/i];
25
28
 
26
29
  function describeAgentShutdown(agent) {
27
30
  const parts = [];
@@ -41,10 +44,79 @@ function describeAgentShutdown(agent) {
41
44
  return parts.join(', ');
42
45
  }
43
46
 
47
+ function isPrivateIpv4(address) {
48
+ return /^10\./.test(address) ||
49
+ /^192\.168\./.test(address) ||
50
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(address);
51
+ }
52
+
53
+ function detectDockerHostsFromInterfaces(networkInterfaces = os.networkInterfaces()) {
54
+ const hosts = [];
55
+
56
+ for (const [name, addresses] of Object.entries(networkInterfaces)) {
57
+ if (!DOCKER_INTERFACE_PATTERNS.some(pattern => pattern.test(name))) {
58
+ continue;
59
+ }
60
+
61
+ for (const info of addresses || []) {
62
+ if (info && info.family === 'IPv4' && !info.internal && isPrivateIpv4(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
+ }
101
+ }
102
+ }
103
+
104
+ if (hosts.length > 0) {
105
+ return hosts;
106
+ }
107
+ } catch (_err) {
108
+ // Fall back to interface detection when Docker is unavailable.
109
+ }
110
+
111
+ return detectDockerHostsFromInterfaces(networkInterfaces);
112
+ }
113
+
44
114
  class AgentTank {
45
115
  constructor(options = {}) {
46
116
  this.port = options.port || 3456;
47
117
  this.host = options.host || '127.0.0.1';
118
+ this.explicitHost = !!options.host;
119
+ this.dockerAccess = options.dockerAccess !== false;
48
120
  this.autoDiscover = options.autoDiscover !== false;
49
121
  this.requestedAgents = options.agents || null;
50
122
  this.freshProcess = options.freshProcess || false;
@@ -52,9 +124,14 @@ class AgentTank {
52
124
  this.auth = options.auth || {};
53
125
  this.agents = new Map();
54
126
  this.server = null;
127
+ this.servers = [];
128
+ this.listenHosts = [];
55
129
  this.publicStatus = {}; // Public API status from upstream providers
56
130
  this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
57
131
  this.lastRefreshedAt = null;
132
+ this.refreshCooldown = Math.max(0, options.refreshCooldown ?? 30);
133
+ this._agentRefreshStartedAt = new Map();
134
+ this._agentRefreshPromises = new Map();
58
135
  this.stopping = false;
59
136
 
60
137
  // Auto-refresh configuration (backend periodic refresh)
@@ -183,7 +260,7 @@ class AgentTank {
183
260
 
184
261
  // Start HTTP server immediately so it's available during agent loading (unless skipped)
185
262
  if (!this.skipServer) {
186
- this.startServer();
263
+ await this.startServer();
187
264
  }
188
265
 
189
266
  // Pre-spawn persistent processes in parallel before sending commands
@@ -271,10 +348,10 @@ class AgentTank {
271
348
  const [, publicStatusResult] = await Promise.all([
272
349
  // Agent PTY refreshes
273
350
  Promise.all(
274
- Array.from(this.agents.values()).map(agent =>
275
- agent.refresh().catch(err => {
351
+ Array.from(this.agents.keys()).map(name =>
352
+ this._refreshAgentWithCooldown(name).catch(err => {
276
353
  if (!this.stopping && err.message !== 'Agent stopping') {
277
- logger.error(`Error refreshing ${agent.name}:`, err.message);
354
+ logger.error(`Error refreshing ${name}:`, err.message);
278
355
  }
279
356
  })
280
357
  )
@@ -319,7 +396,7 @@ class AgentTank {
319
396
  if (!agent) {
320
397
  throw new Error(`Agent not found: ${name}`);
321
398
  }
322
- await agent.refresh();
399
+ await this._refreshAgentWithCooldown(name);
323
400
 
324
401
  // Record snapshot and attach pace evaluation for single agent refresh
325
402
  if (agent.usage) {
@@ -328,6 +405,47 @@ class AgentTank {
328
405
  }
329
406
  }
330
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
+
331
449
  /** Get usage history statistics. @returns {Object} */
332
450
  getHistoryStats() { return this.historyStore.getStats(); }
333
451
 
@@ -377,12 +495,70 @@ class AgentTank {
377
495
  }
378
496
 
379
497
  startServer() {
380
- this.server = createServer(this);
381
- this.server.listen(this.port, this.host, () => {
498
+ const hosts = this._resolveListenHosts();
499
+
500
+ return Promise.all(hosts.map(async (host, index) => {
501
+ const server = createServer(this);
502
+ try {
503
+ await new Promise((resolve, reject) => {
504
+ const onError = (err) => {
505
+ server.off('listening', onListening);
506
+ reject(err);
507
+ };
508
+ const onListening = () => {
509
+ server.off('error', onError);
510
+ resolve();
511
+ };
512
+ server.once('error', onError);
513
+ server.once('listening', onListening);
514
+ server.listen(this.port, host);
515
+ });
516
+ this.servers.push(server);
517
+ this.listenHosts.push(host);
518
+ } catch (err) {
519
+ try {
520
+ server.close();
521
+ } catch (_closeErr) {
522
+ // Ignore cleanup errors for failed listeners
523
+ }
524
+
525
+ if (index === 0) {
526
+ throw err;
527
+ }
528
+
529
+ logger.warn(`Could not bind optional host ${host}:${this.port} (${err.message})`);
530
+ }
531
+ })).then(() => {
532
+ this.server = this.servers[0] || null;
533
+ if (this.listenHosts.length > 0) {
534
+ this.host = this.listenHosts[0];
535
+ }
382
536
  displayServerBanner(this);
383
537
  });
384
538
  }
385
539
 
540
+ _resolveListenHosts(detectHost = detectDockerHosts) {
541
+ if (this.explicitHost) {
542
+ return [this.host];
543
+ }
544
+
545
+ const hosts = ['127.0.0.1'];
546
+ if (this.dockerAccess) {
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
+ }
556
+ }
557
+ }
558
+
559
+ return hosts;
560
+ }
561
+
386
562
  stop() {
387
563
  this.stopping = true;
388
564
  console.log('[Shutdown] Stopping Agent Tank...');
@@ -395,10 +571,15 @@ class AgentTank {
395
571
  }
396
572
  agent.killProcess();
397
573
  }
398
- if (this.server) {
574
+ if (this.servers.length > 0) {
399
575
  console.log('[Shutdown] Closing HTTP server');
400
- this.server.close();
576
+ for (const server of this.servers) {
577
+ server.close();
578
+ }
401
579
  }
580
+ this.server = null;
581
+ this.servers = [];
582
+ this.listenHosts = [];
402
583
  }
403
584
 
404
585
  /** Get keepalive status. @returns {Object|null} Keepalive manager status or null if not initialized */
@@ -415,8 +596,15 @@ class AgentTank {
415
596
 
416
597
  /** Get the auto-refresh configuration. @returns {Object} */
417
598
  getAutoRefreshConfig() {
418
- 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
+ };
419
607
  }
420
608
  }
421
609
 
422
- module.exports = { AgentTank, AUTO_REFRESH_MODES };
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
  };
@@ -135,9 +136,12 @@ function displayServerBanner(tank) {
135
136
  if (tank.auth.token) {
136
137
  logger.info('🔑 Authentication: API key');
137
138
  }
138
- logger.server(`Listening on: ${tank.host}:${tank.port}`);
139
- logger.server(`📄 Status page: http://${tank.host}:${tank.port}/`);
140
- logger.server(`📡 JSON API: http://${tank.host}:${tank.port}/status`);
139
+ const listenHosts = tank.listenHosts && tank.listenHosts.length > 0 ? tank.listenHosts : [tank.host];
140
+ logger.server(`Listening on: ${listenHosts.map(host => `${host}:${tank.port}`).join(', ')}`);
141
+ for (const host of listenHosts) {
142
+ logger.server(`📄 Status page: http://${host}:${tank.port}`);
143
+ logger.server(`📡 JSON API: http://${host}:${tank.port}/status`);
144
+ }
141
145
  console.log('');
142
146
  }
143
147
 
@@ -49,10 +49,10 @@ function getStatusDotClass(value) {
49
49
  }
50
50
 
51
51
  function resetInfoItem(resetsIn, originalValue, cycleType, options = {}) {
52
- const { isZero = false, paceData = null } = options;
52
+ const { paceData = null } = options;
53
53
  // Always render the wrapper so XHR updates can show/hide it dynamically.
54
- // Hidden when at 0% (no useful info when at full capacity).
55
- const hidden = isZero ? ' style="display:none"' : '';
54
+ // Hidden only when there is no reset information to show.
55
+ const hidden = resetsIn ? '' : ' style="display:none"';
56
56
  const tooltip = originalValue ? ` title="${originalValue}"` : '';
57
57
 
58
58
  // Calculate elapsed time progress bar