agent-tank 0.9.2 → 0.9.4
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 +313 -443
- package/bin/agent-tank.js +34 -6
- package/package.json +1 -1
- package/src/agents/base.js +110 -2
- package/src/agents/claude.js +10 -28
- package/src/agents/codex.js +3 -0
- package/src/client-auto-refresh.js +2 -2
- package/src/index.js +141 -10
- package/src/json-rpc-client.js +3 -0
- package/src/server.js +6 -3
- package/src/shutdown-handler.js +32 -0
- package/src/usage-formatters.js +3 -3
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
|
+
const os = require('node:os');
|
|
2
3
|
const { ClaudeAgent } = require('./agents/claude.js');
|
|
3
4
|
const { GeminiAgent } = require('./agents/gemini.js');
|
|
4
5
|
const { CodexAgent } = require('./agents/codex.js');
|
|
@@ -22,11 +23,54 @@ const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
|
22
23
|
* - 'activity': Activity-based polling (refreshes when log activity is detected)
|
|
23
24
|
*/
|
|
24
25
|
const AUTO_REFRESH_MODES = ['none', 'interval', 'activity'];
|
|
26
|
+
const DOCKER_INTERFACE_PATTERNS = [/^docker\d*$/i, /^br-/i, /^podman\d*$/i];
|
|
27
|
+
|
|
28
|
+
function describeAgentShutdown(agent) {
|
|
29
|
+
const parts = [];
|
|
30
|
+
|
|
31
|
+
if (agent.shell && typeof agent.shell.pid === 'number') {
|
|
32
|
+
parts.push(`pty pid ${agent.shell.pid}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (agent._rpcClient) {
|
|
36
|
+
parts.push('json-rpc client');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (parts.length === 0) {
|
|
40
|
+
parts.push('no active child process');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return parts.join(', ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPrivateIpv4(address) {
|
|
47
|
+
return /^10\./.test(address) ||
|
|
48
|
+
/^192\.168\./.test(address) ||
|
|
49
|
+
/^172\.(1[6-9]|2\d|3[0-1])\./.test(address);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function detectDockerHost(networkInterfaces = os.networkInterfaces()) {
|
|
53
|
+
for (const [name, addresses] of Object.entries(networkInterfaces)) {
|
|
54
|
+
if (!DOCKER_INTERFACE_PATTERNS.some(pattern => pattern.test(name))) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const info of addresses || []) {
|
|
59
|
+
if (info && info.family === 'IPv4' && !info.internal && isPrivateIpv4(info.address)) {
|
|
60
|
+
return info.address;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
25
67
|
|
|
26
68
|
class AgentTank {
|
|
27
69
|
constructor(options = {}) {
|
|
28
70
|
this.port = options.port || 3456;
|
|
29
71
|
this.host = options.host || '127.0.0.1';
|
|
72
|
+
this.explicitHost = !!options.host;
|
|
73
|
+
this.dockerAccess = options.dockerAccess !== false;
|
|
30
74
|
this.autoDiscover = options.autoDiscover !== false;
|
|
31
75
|
this.requestedAgents = options.agents || null;
|
|
32
76
|
this.freshProcess = options.freshProcess || false;
|
|
@@ -34,9 +78,12 @@ class AgentTank {
|
|
|
34
78
|
this.auth = options.auth || {};
|
|
35
79
|
this.agents = new Map();
|
|
36
80
|
this.server = null;
|
|
81
|
+
this.servers = [];
|
|
82
|
+
this.listenHosts = [];
|
|
37
83
|
this.publicStatus = {}; // Public API status from upstream providers
|
|
38
84
|
this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
|
|
39
85
|
this.lastRefreshedAt = null;
|
|
86
|
+
this.stopping = false;
|
|
40
87
|
|
|
41
88
|
// Auto-refresh configuration (backend periodic refresh)
|
|
42
89
|
// Determine the mode: 'none', 'interval', or 'activity' (default: 'activity')
|
|
@@ -121,6 +168,10 @@ class AgentTank {
|
|
|
121
168
|
}
|
|
122
169
|
|
|
123
170
|
async start() {
|
|
171
|
+
if (this.stopping) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
124
175
|
// Display startup banner with website URL, version, and copyright
|
|
125
176
|
this.displayStartupBanner();
|
|
126
177
|
|
|
@@ -160,7 +211,7 @@ class AgentTank {
|
|
|
160
211
|
|
|
161
212
|
// Start HTTP server immediately so it's available during agent loading (unless skipped)
|
|
162
213
|
if (!this.skipServer) {
|
|
163
|
-
this.startServer();
|
|
214
|
+
await this.startServer();
|
|
164
215
|
}
|
|
165
216
|
|
|
166
217
|
// Pre-spawn persistent processes in parallel before sending commands
|
|
@@ -169,18 +220,24 @@ class AgentTank {
|
|
|
169
220
|
await Promise.all(
|
|
170
221
|
Array.from(this.agents.values()).map(agent =>
|
|
171
222
|
agent.spawnProcess().catch(err =>
|
|
172
|
-
|
|
223
|
+
this.stopping || err.message === 'Agent stopping'
|
|
224
|
+
? null
|
|
225
|
+
: logger.error(`Error spawning ${agent.name}:`, err.message)
|
|
173
226
|
)
|
|
174
227
|
)
|
|
175
228
|
);
|
|
176
229
|
}
|
|
177
230
|
|
|
231
|
+
if (this.stopping) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
178
235
|
// Initial fetch
|
|
179
236
|
logger.info('📊 Fetching initial usage data...');
|
|
180
237
|
await this.refreshAll();
|
|
181
238
|
|
|
182
239
|
// Start backend auto-refresh if enabled (skip in one-shot mode)
|
|
183
|
-
if (!this.skipServer) {
|
|
240
|
+
if (!this.skipServer && !this.stopping) {
|
|
184
241
|
this.startAutoRefresh();
|
|
185
242
|
this.startKeepalive();
|
|
186
243
|
}
|
|
@@ -231,6 +288,10 @@ class AgentTank {
|
|
|
231
288
|
}
|
|
232
289
|
|
|
233
290
|
async refreshAll() {
|
|
291
|
+
if (this.stopping) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
234
295
|
logger.info('🔄 Refreshing all agents...');
|
|
235
296
|
const agentNames = Array.from(this.agents.keys());
|
|
236
297
|
|
|
@@ -240,7 +301,9 @@ class AgentTank {
|
|
|
240
301
|
Promise.all(
|
|
241
302
|
Array.from(this.agents.values()).map(agent =>
|
|
242
303
|
agent.refresh().catch(err => {
|
|
243
|
-
|
|
304
|
+
if (!this.stopping && err.message !== 'Agent stopping') {
|
|
305
|
+
logger.error(`Error refreshing ${agent.name}:`, err.message);
|
|
306
|
+
}
|
|
244
307
|
})
|
|
245
308
|
)
|
|
246
309
|
),
|
|
@@ -276,6 +339,10 @@ class AgentTank {
|
|
|
276
339
|
}
|
|
277
340
|
|
|
278
341
|
async refreshAgent(name) {
|
|
342
|
+
if (this.stopping) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
279
346
|
const agent = this.agents.get(name);
|
|
280
347
|
if (!agent) {
|
|
281
348
|
throw new Error(`Agent not found: ${name}`);
|
|
@@ -338,21 +405,85 @@ class AgentTank {
|
|
|
338
405
|
}
|
|
339
406
|
|
|
340
407
|
startServer() {
|
|
341
|
-
|
|
342
|
-
|
|
408
|
+
const hosts = this._resolveListenHosts();
|
|
409
|
+
|
|
410
|
+
return Promise.all(hosts.map(async (host, index) => {
|
|
411
|
+
const server = createServer(this);
|
|
412
|
+
try {
|
|
413
|
+
await new Promise((resolve, reject) => {
|
|
414
|
+
const onError = (err) => {
|
|
415
|
+
server.off('listening', onListening);
|
|
416
|
+
reject(err);
|
|
417
|
+
};
|
|
418
|
+
const onListening = () => {
|
|
419
|
+
server.off('error', onError);
|
|
420
|
+
resolve();
|
|
421
|
+
};
|
|
422
|
+
server.once('error', onError);
|
|
423
|
+
server.once('listening', onListening);
|
|
424
|
+
server.listen(this.port, host);
|
|
425
|
+
});
|
|
426
|
+
this.servers.push(server);
|
|
427
|
+
this.listenHosts.push(host);
|
|
428
|
+
} catch (err) {
|
|
429
|
+
try {
|
|
430
|
+
server.close();
|
|
431
|
+
} catch (_closeErr) {
|
|
432
|
+
// Ignore cleanup errors for failed listeners
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (index === 0) {
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
logger.warn(`Could not bind optional host ${host}:${this.port} (${err.message})`);
|
|
440
|
+
}
|
|
441
|
+
})).then(() => {
|
|
442
|
+
this.server = this.servers[0] || null;
|
|
443
|
+
if (this.listenHosts.length > 0) {
|
|
444
|
+
this.host = this.listenHosts[0];
|
|
445
|
+
}
|
|
343
446
|
displayServerBanner(this);
|
|
344
447
|
});
|
|
345
448
|
}
|
|
346
449
|
|
|
450
|
+
_resolveListenHosts(detectHost = detectDockerHost) {
|
|
451
|
+
if (this.explicitHost) {
|
|
452
|
+
return [this.host];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const hosts = ['127.0.0.1'];
|
|
456
|
+
if (this.dockerAccess) {
|
|
457
|
+
const dockerHost = detectHost();
|
|
458
|
+
if (dockerHost && dockerHost !== '127.0.0.1') {
|
|
459
|
+
hosts.push(dockerHost);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return hosts;
|
|
464
|
+
}
|
|
465
|
+
|
|
347
466
|
stop() {
|
|
467
|
+
this.stopping = true;
|
|
468
|
+
console.log('[Shutdown] Stopping Agent Tank...');
|
|
348
469
|
this.stopAutoRefresh();
|
|
349
470
|
this.stopKeepalive();
|
|
350
|
-
for (const agent of this.agents
|
|
471
|
+
for (const [name, agent] of this.agents) {
|
|
472
|
+
console.log(`[Shutdown] ${name}: ${describeAgentShutdown(agent)}`);
|
|
473
|
+
if (typeof agent.requestStop === 'function') {
|
|
474
|
+
agent.requestStop();
|
|
475
|
+
}
|
|
351
476
|
agent.killProcess();
|
|
352
477
|
}
|
|
353
|
-
if (this.
|
|
354
|
-
|
|
478
|
+
if (this.servers.length > 0) {
|
|
479
|
+
console.log('[Shutdown] Closing HTTP server');
|
|
480
|
+
for (const server of this.servers) {
|
|
481
|
+
server.close();
|
|
482
|
+
}
|
|
355
483
|
}
|
|
484
|
+
this.server = null;
|
|
485
|
+
this.servers = [];
|
|
486
|
+
this.listenHosts = [];
|
|
356
487
|
}
|
|
357
488
|
|
|
358
489
|
/** Get keepalive status. @returns {Object|null} Keepalive manager status or null if not initialized */
|
|
@@ -373,4 +504,4 @@ class AgentTank {
|
|
|
373
504
|
}
|
|
374
505
|
}
|
|
375
506
|
|
|
376
|
-
module.exports = { AgentTank, AUTO_REFRESH_MODES };
|
|
507
|
+
module.exports = { AgentTank, AUTO_REFRESH_MODES, detectDockerHost };
|
package/src/json-rpc-client.js
CHANGED
|
@@ -44,6 +44,9 @@ class JsonRpcClient extends EventEmitter {
|
|
|
44
44
|
env: this.options.env,
|
|
45
45
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
46
|
});
|
|
47
|
+
if (typeof this.process.pid === 'number') {
|
|
48
|
+
console.log(`[JsonRpcClient] Started ${this.command} (pid ${this.process.pid})`);
|
|
49
|
+
}
|
|
47
50
|
|
|
48
51
|
this.process.on('error', (err) => {
|
|
49
52
|
this.isConnected = false;
|
package/src/server.js
CHANGED
|
@@ -135,9 +135,12 @@ function displayServerBanner(tank) {
|
|
|
135
135
|
if (tank.auth.token) {
|
|
136
136
|
logger.info('🔑 Authentication: API key');
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
logger.server(
|
|
140
|
-
|
|
138
|
+
const listenHosts = tank.listenHosts && tank.listenHosts.length > 0 ? tank.listenHosts : [tank.host];
|
|
139
|
+
logger.server(`Listening on: ${listenHosts.map(host => `${host}:${tank.port}`).join(', ')}`);
|
|
140
|
+
for (const host of listenHosts) {
|
|
141
|
+
logger.server(`📄 Status page: http://${host}:${tank.port}`);
|
|
142
|
+
logger.server(`📡 JSON API: http://${host}:${tank.port}/status`);
|
|
143
|
+
}
|
|
141
144
|
console.log('');
|
|
142
145
|
}
|
|
143
146
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function installShutdownHandlers({ shutdown, processObj = process, stdin = process.stdin }) {
|
|
2
|
+
const onSigterm = () => shutdown('SIGTERM');
|
|
3
|
+
const onStdinData = (chunk) => {
|
|
4
|
+
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ''));
|
|
5
|
+
if (data.includes(0x03)) {
|
|
6
|
+
shutdown('CTRL+C');
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
processObj.on('SIGTERM', onSigterm);
|
|
11
|
+
|
|
12
|
+
const shouldWatchStdin = stdin && stdin.isTTY && typeof stdin.on === 'function';
|
|
13
|
+
if (shouldWatchStdin) {
|
|
14
|
+
stdin.on('data', onStdinData);
|
|
15
|
+
if (typeof stdin.resume === 'function') {
|
|
16
|
+
stdin.resume();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
processObj.off('SIGTERM', onSigterm);
|
|
22
|
+
|
|
23
|
+
if (shouldWatchStdin) {
|
|
24
|
+
stdin.off('data', onStdinData);
|
|
25
|
+
if (typeof stdin.pause === 'function') {
|
|
26
|
+
stdin.pause();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { installShutdownHandlers };
|
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
|