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/README.md +325 -438
- package/bin/agent-tank.js +28 -1
- package/package.json +1 -1
- package/src/agents/base.js +1 -1
- package/src/agents/claude.js +10 -28
- package/src/agents/gemini.js +20 -18
- package/src/auto-refresh-manager.js +4 -5
- package/src/client-auto-refresh.js +2 -2
- package/src/index.js +199 -11
- package/src/server.js +7 -3
- package/src/usage-formatters.js +3 -3
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
package/src/agents/base.js
CHANGED
|
@@ -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,
|
|
332
|
+
this.sendCommands(this.shell, existingOutput);
|
|
333
333
|
});
|
|
334
334
|
}
|
|
335
335
|
|
package/src/agents/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
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
|
-
//
|
|
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
|
package/src/agents/gemini.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
44
|
-
if (clean.includes('Type your message') || clean.includes('? for shortcuts')) {
|
|
48
|
+
if (!hasAuthFlow) {
|
|
45
49
|
return false;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
163
|
-
//
|
|
164
|
-
setTimeout(() => shell.write('
|
|
165
|
-
setTimeout(() => shell.write('
|
|
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
|
-
|
|
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);
|
|
@@ -87,8 +87,8 @@ ${metricExtractors}
|
|
|
87
87
|
if (modelContainer) {
|
|
88
88
|
const resetWrapper = modelContainer.querySelector('.reset-info-wrapper');
|
|
89
89
|
if (resetWrapper) {
|
|
90
|
-
if (
|
|
91
|
-
// Hide
|
|
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.
|
|
275
|
-
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
381
|
-
|
|
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.
|
|
574
|
+
if (this.servers.length > 0) {
|
|
399
575
|
console.log('[Shutdown] Closing HTTP server');
|
|
400
|
-
this.
|
|
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 {
|
|
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
|
-
|
|
139
|
-
logger.server(
|
|
140
|
-
|
|
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
|
|
package/src/usage-formatters.js
CHANGED
|
@@ -49,10 +49,10 @@ function getStatusDotClass(value) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function resetInfoItem(resetsIn, originalValue, cycleType, options = {}) {
|
|
52
|
-
const {
|
|
52
|
+
const { paceData = null } = options;
|
|
53
53
|
// Always render the wrapper so XHR updates can show/hide it dynamically.
|
|
54
|
-
// Hidden when
|
|
55
|
-
const hidden =
|
|
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
|