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/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
- logger.error(`Error spawning ${agent.name}:`, err.message)
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
- logger.error(`Error refreshing ${agent.name}:`, err.message);
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
- this.server = createServer(this);
342
- this.server.listen(this.port, this.host, () => {
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.values()) {
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.server) {
354
- this.server.close();
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 };
@@ -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
- 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`);
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 };
@@ -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