agent-tank 0.9.2 → 0.9.3

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
@@ -3,6 +3,8 @@
3
3
 
4
4
  const { parseArgs } = require('node:util');
5
5
  const { AgentTank } = require('../src/index.js');
6
+ const { installShutdownHandlers } = require('../src/shutdown-handler.js');
7
+ const pkg = require('../package.json');
6
8
 
7
9
  const options = {
8
10
  claude: { type: 'boolean', default: false },
@@ -17,6 +19,7 @@ const options = {
17
19
  'fresh-process': { type: 'boolean', default: false },
18
20
  config: { type: 'string', short: 'c' },
19
21
  help: { type: 'boolean', short: 'h', default: false },
22
+ version: { type: 'boolean', short: 'v', default: false },
20
23
  'auto-discover': { type: 'boolean', default: true },
21
24
  'auto-refresh': { type: 'boolean', default: true },
22
25
  'auto-refresh-interval': { type: 'string', default: '60' },
@@ -56,6 +59,7 @@ Options:
56
59
  --auth-token <token> API key for Bearer token auth
57
60
  --fresh-process Spawn a new process per refresh (default: false)
58
61
  --config, -c Path to config file (JSON)
62
+ --version, -v Show version
59
63
  --auto-discover Auto-discover available agents (default: true)
60
64
  --auto-refresh Enable/disable background auto-refresh (default: true)
61
65
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
@@ -127,6 +131,12 @@ async function main() {
127
131
  return;
128
132
  }
129
133
 
134
+ if (values.version) {
135
+ process.stdout.write(`${pkg.version}\n`);
136
+ process.exitCode = 0;
137
+ return;
138
+ }
139
+
130
140
  // Load config file if specified
131
141
  let config = {};
132
142
  if (values.config) {
@@ -255,15 +265,19 @@ async function main() {
255
265
  });
256
266
 
257
267
  // Graceful shutdown
258
- const shutdown = () => {
268
+ let shuttingDown = false;
269
+ let cleanupShutdownHandlers = () => {};
270
+ const shutdown = (signal) => {
271
+ if (shuttingDown) return;
272
+ shuttingDown = true;
273
+ cleanupShutdownHandlers();
259
274
  if (!jsonMode) {
260
- originalLog('\nShutting down...');
275
+ originalLog(`\nShutting down${signal ? ` (${signal})` : ''}...`);
261
276
  }
262
277
  watcher.stop();
263
- process.exitCode = 0;
278
+ process.exit(0);
264
279
  };
265
- process.on('SIGINT', shutdown);
266
- process.on('SIGTERM', shutdown);
280
+ cleanupShutdownHandlers = installShutdownHandlers({ shutdown });
267
281
 
268
282
  try {
269
283
  if (onceMode) {
@@ -281,6 +295,7 @@ async function main() {
281
295
  originalLog(JSON.stringify(agentStatus, null, 2));
282
296
  }
283
297
  }
298
+ cleanupShutdownHandlers();
284
299
  watcher.stop();
285
300
  process.exitCode = 0;
286
301
  return;
@@ -288,6 +303,7 @@ async function main() {
288
303
 
289
304
  await watcher.start();
290
305
  } catch (err) {
306
+ cleanupShutdownHandlers();
291
307
  if (jsonMode) {
292
308
  console.log = originalLog;
293
309
  console.log(JSON.stringify({ error: err.message }, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
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": {
@@ -1,4 +1,5 @@
1
1
  const pty = require('node-pty');
2
+ const process = require('node:process');
2
3
  const logger = require('../logger.js');
3
4
 
4
5
  class BaseAgent {
@@ -24,6 +25,22 @@ class BaseAgent {
24
25
  this._onDataCallback = null;
25
26
  this._commandInFlight = false;
26
27
  this._disposables = [];
28
+ this._stopRequested = false;
29
+ }
30
+
31
+ _createStopError() {
32
+ return new Error('Agent stopping');
33
+ }
34
+
35
+ isStopping() {
36
+ return this._stopRequested;
37
+ }
38
+
39
+ requestStop() {
40
+ this._stopRequested = true;
41
+ this.isRefreshing = false;
42
+ this._commandInFlight = false;
43
+ this._onDataCallback = null;
27
44
  }
28
45
 
29
46
  getStatus() {
@@ -39,6 +56,11 @@ class BaseAgent {
39
56
  }
40
57
 
41
58
  async refresh() {
59
+ if (this.isStopping()) {
60
+ logger.agent(this.name, 'Skipping refresh while stopping');
61
+ return;
62
+ }
63
+
42
64
  if (this.isRefreshing) {
43
65
  logger.agent(this.name, 'Already refreshing, skipping...');
44
66
  return;
@@ -68,6 +90,10 @@ class BaseAgent {
68
90
  }
69
91
  }
70
92
 
93
+ if (this.isStopping()) {
94
+ throw this._createStopError();
95
+ }
96
+
71
97
  const output = await this.runCommand();
72
98
  logger.agent(this.name, 'Got output, length:', logger.dim(`${output.length} chars`));
73
99
 
@@ -108,6 +134,10 @@ class BaseAgent {
108
134
  if (hasData) { this.usage = parsed; this.lastUpdated = new Date().toISOString(); } else if (this.usage) { logger.agent(this.name, 'Parse returned no data, preserving last known usage'); this.error = 'Failed to parse — using cached data'; } else { this.usage = parsed; this.lastUpdated = new Date().toISOString(); }
109
135
  logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
110
136
  } catch (err) {
137
+ if (this.isStopping() && err.message === 'Agent stopping') {
138
+ logger.agent(this.name, 'Refresh cancelled during shutdown');
139
+ return;
140
+ }
111
141
  logger.error(`[${this.name}] Error during refresh:`, err.message);
112
142
  this.error = err.message;
113
143
  } finally {
@@ -117,6 +147,10 @@ class BaseAgent {
117
147
  }
118
148
 
119
149
  async runCommand() {
150
+ if (this.isStopping()) {
151
+ throw this._createStopError();
152
+ }
153
+
120
154
  if (this.freshProcess) {
121
155
  return this._runCommandFresh();
122
156
  }
@@ -130,6 +164,10 @@ class BaseAgent {
130
164
  }
131
165
 
132
166
  async spawnProcess() {
167
+ if (this.isStopping()) {
168
+ throw this._createStopError();
169
+ }
170
+
133
171
  return new Promise((resolve, reject) => {
134
172
  let trustHandled = false;
135
173
  let spawnOutput = '';
@@ -145,6 +183,9 @@ class BaseAgent {
145
183
  cwd: '/tmp',
146
184
  env,
147
185
  });
186
+ if (typeof this.shell.pid === 'number') {
187
+ logger.agent(this.name, 'Persistent process pid:', logger.dim(String(this.shell.pid)));
188
+ }
148
189
  } catch (spawnErr) {
149
190
  logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
150
191
  reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
@@ -152,6 +193,10 @@ class BaseAgent {
152
193
  }
153
194
 
154
195
  const timer = setTimeout(() => {
196
+ if (this.isStopping()) {
197
+ reject(this._createStopError());
198
+ return;
199
+ }
155
200
  logger.error(`[${this.name}] Spawn timeout after ${this.getTimeout()}ms`);
156
201
  this.killProcess();
157
202
  reject(new Error('Timeout waiting for process to become ready'));
@@ -159,6 +204,15 @@ class BaseAgent {
159
204
 
160
205
  let trustCooldownUntil = 0;
161
206
  const spawnDataHandler = this.shell.onData((data) => {
207
+ if (this.isStopping()) {
208
+ clearTimeout(timer);
209
+ spawnDataHandler.dispose();
210
+ spawnExitHandler.dispose();
211
+ this.killProcess();
212
+ reject(this._createStopError());
213
+ return;
214
+ }
215
+
162
216
  spawnOutput += data;
163
217
 
164
218
  // Handle trust/update prompts during spawn (only once each)
@@ -202,12 +256,20 @@ class BaseAgent {
202
256
  spawnExitHandler.dispose();
203
257
  this.shell = null;
204
258
  this.processReady = false;
259
+ if (this.isStopping()) {
260
+ reject(this._createStopError());
261
+ return;
262
+ }
205
263
  reject(new Error(`Process exited during spawn with code ${exitCode}`));
206
264
  });
207
265
  });
208
266
  }
209
267
 
210
268
  async sendCommandAndWait() {
269
+ if (this.isStopping()) {
270
+ throw this._createStopError();
271
+ }
272
+
211
273
  return new Promise((resolve, reject) => {
212
274
  const existingOutput = this.output;
213
275
  const existingAuthState = this.detectAuthenticationState ? this.detectAuthenticationState(existingOutput) : null;
@@ -228,6 +290,12 @@ class BaseAgent {
228
290
  };
229
291
 
230
292
  const timer = setTimeout(() => {
293
+ if (this.isStopping()) {
294
+ this._commandInFlight = false;
295
+ this._onDataCallback = null;
296
+ reject(this._createStopError());
297
+ return;
298
+ }
231
299
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
232
300
  if (this.output.length > 0) {
233
301
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
@@ -245,6 +313,13 @@ class BaseAgent {
245
313
  }, this.getTimeout());
246
314
 
247
315
  this._onDataCallback = () => {
316
+ if (this.isStopping()) {
317
+ this._commandInFlight = false;
318
+ this._onDataCallback = null;
319
+ clearTimeout(timer);
320
+ reject(this._createStopError());
321
+ return;
322
+ }
248
323
  if (this.hasCompleteOutput(this.output)) {
249
324
  logger.agent(this.name, 'Complete output detected');
250
325
  // Small delay to capture any remaining output
@@ -298,15 +373,27 @@ class BaseAgent {
298
373
  _handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
299
374
  killProcess() { // Terminates the persistent PTY process
300
375
  if (this.shell) {
301
- logger.agent(this.name, 'Killing persistent process');
376
+ const pidText = typeof this.shell.pid === 'number' ? ` (pid ${this.shell.pid})` : '';
377
+ logger.agent(this.name, `Killing persistent process${pidText}`);
378
+ const shell = this.shell;
302
379
  for (const d of this._disposables) { d.dispose(); }
303
380
  this._disposables = []; this._onDataCallback = null; this.processReady = false;
304
- try { this.shell.kill(); } catch (_e) { /* Process may already be dead */ }
381
+ try {
382
+ // PTY-backed CLIs may spawn descendants; kill the whole process group when available.
383
+ if (typeof shell.pid === 'number' && shell.pid > 0) {
384
+ try { process.kill(-shell.pid, 'SIGTERM'); } catch (_e) { /* Group may not exist */ }
385
+ }
386
+ shell.kill();
387
+ } catch (_e) { /* Process may already be dead */ }
305
388
  this.shell = null;
306
389
  }
307
390
  }
308
391
 
309
392
  async _runCommandFresh() {
393
+ if (this.isStopping()) {
394
+ throw this._createStopError();
395
+ }
396
+
310
397
  return new Promise((resolve, reject) => {
311
398
  const timeout = this.getTimeout();
312
399
  let output = '';
@@ -334,6 +421,12 @@ class BaseAgent {
334
421
 
335
422
  const timer = setTimeout(() => {
336
423
  if (!completed) {
424
+ if (this.isStopping()) {
425
+ completed = true;
426
+ shell.kill();
427
+ reject(this._createStopError());
428
+ return;
429
+ }
337
430
  completed = true;
338
431
  logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
339
432
  if (output.length > 0) {
@@ -352,6 +445,16 @@ class BaseAgent {
352
445
 
353
446
  let trustCooldownUntil = 0;
354
447
  shell.onData((data) => {
448
+ if (this.isStopping()) {
449
+ if (!completed) {
450
+ completed = true;
451
+ clearTimeout(timer);
452
+ shell.kill();
453
+ reject(this._createStopError());
454
+ }
455
+ return;
456
+ }
457
+
355
458
  output += data;
356
459
 
357
460
  if (output.length <= data.length) {
@@ -395,6 +498,10 @@ class BaseAgent {
395
498
  if (!completed) {
396
499
  completed = true;
397
500
  clearTimeout(timer);
501
+ if (this.isStopping()) {
502
+ reject(this._createStopError());
503
+ return;
504
+ }
398
505
  logger.agent(this.name, 'Process exited with code', logger.dim(exitCode), ', output length:', logger.dim(`${output.length}`));
399
506
  if (output) {
400
507
  resolve(output);
@@ -414,6 +521,7 @@ class BaseAgent {
414
521
 
415
522
  /** Lightweight keepalive method to prevent session expiration. Subclasses can override. @returns {Promise<boolean>} True if keepalive succeeded */
416
523
  async keepalive() {
524
+ if (this.isStopping()) { return false; }
417
525
  if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
418
526
  if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
419
527
  if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
@@ -156,6 +156,9 @@ class CodexAgent extends BaseAgent {
156
156
  cwd: '/tmp',
157
157
  env: { ...process.env, TERM: 'xterm-256color' },
158
158
  });
159
+ if (typeof this.shell.pid === 'number') {
160
+ logger.agent(this.name, 'Persistent process pid:', logger.dim(String(this.shell.pid)));
161
+ }
159
162
  } catch (spawnErr) {
160
163
  logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
161
164
  reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
package/src/index.js CHANGED
@@ -23,6 +23,24 @@ const pkg = require(path.join(__dirname, '..', 'package.json'));
23
23
  */
24
24
  const AUTO_REFRESH_MODES = ['none', 'interval', 'activity'];
25
25
 
26
+ function describeAgentShutdown(agent) {
27
+ const parts = [];
28
+
29
+ if (agent.shell && typeof agent.shell.pid === 'number') {
30
+ parts.push(`pty pid ${agent.shell.pid}`);
31
+ }
32
+
33
+ if (agent._rpcClient) {
34
+ parts.push('json-rpc client');
35
+ }
36
+
37
+ if (parts.length === 0) {
38
+ parts.push('no active child process');
39
+ }
40
+
41
+ return parts.join(', ');
42
+ }
43
+
26
44
  class AgentTank {
27
45
  constructor(options = {}) {
28
46
  this.port = options.port || 3456;
@@ -37,6 +55,7 @@ class AgentTank {
37
55
  this.publicStatus = {}; // Public API status from upstream providers
38
56
  this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
39
57
  this.lastRefreshedAt = null;
58
+ this.stopping = false;
40
59
 
41
60
  // Auto-refresh configuration (backend periodic refresh)
42
61
  // Determine the mode: 'none', 'interval', or 'activity' (default: 'activity')
@@ -121,6 +140,10 @@ class AgentTank {
121
140
  }
122
141
 
123
142
  async start() {
143
+ if (this.stopping) {
144
+ return;
145
+ }
146
+
124
147
  // Display startup banner with website URL, version, and copyright
125
148
  this.displayStartupBanner();
126
149
 
@@ -169,18 +192,24 @@ class AgentTank {
169
192
  await Promise.all(
170
193
  Array.from(this.agents.values()).map(agent =>
171
194
  agent.spawnProcess().catch(err =>
172
- logger.error(`Error spawning ${agent.name}:`, err.message)
195
+ this.stopping || err.message === 'Agent stopping'
196
+ ? null
197
+ : logger.error(`Error spawning ${agent.name}:`, err.message)
173
198
  )
174
199
  )
175
200
  );
176
201
  }
177
202
 
203
+ if (this.stopping) {
204
+ return;
205
+ }
206
+
178
207
  // Initial fetch
179
208
  logger.info('📊 Fetching initial usage data...');
180
209
  await this.refreshAll();
181
210
 
182
211
  // Start backend auto-refresh if enabled (skip in one-shot mode)
183
- if (!this.skipServer) {
212
+ if (!this.skipServer && !this.stopping) {
184
213
  this.startAutoRefresh();
185
214
  this.startKeepalive();
186
215
  }
@@ -231,6 +260,10 @@ class AgentTank {
231
260
  }
232
261
 
233
262
  async refreshAll() {
263
+ if (this.stopping) {
264
+ return;
265
+ }
266
+
234
267
  logger.info('🔄 Refreshing all agents...');
235
268
  const agentNames = Array.from(this.agents.keys());
236
269
 
@@ -240,7 +273,9 @@ class AgentTank {
240
273
  Promise.all(
241
274
  Array.from(this.agents.values()).map(agent =>
242
275
  agent.refresh().catch(err => {
243
- logger.error(`Error refreshing ${agent.name}:`, err.message);
276
+ if (!this.stopping && err.message !== 'Agent stopping') {
277
+ logger.error(`Error refreshing ${agent.name}:`, err.message);
278
+ }
244
279
  })
245
280
  )
246
281
  ),
@@ -276,6 +311,10 @@ class AgentTank {
276
311
  }
277
312
 
278
313
  async refreshAgent(name) {
314
+ if (this.stopping) {
315
+ return;
316
+ }
317
+
279
318
  const agent = this.agents.get(name);
280
319
  if (!agent) {
281
320
  throw new Error(`Agent not found: ${name}`);
@@ -345,12 +384,19 @@ class AgentTank {
345
384
  }
346
385
 
347
386
  stop() {
387
+ this.stopping = true;
388
+ console.log('[Shutdown] Stopping Agent Tank...');
348
389
  this.stopAutoRefresh();
349
390
  this.stopKeepalive();
350
- for (const agent of this.agents.values()) {
391
+ for (const [name, agent] of this.agents) {
392
+ console.log(`[Shutdown] ${name}: ${describeAgentShutdown(agent)}`);
393
+ if (typeof agent.requestStop === 'function') {
394
+ agent.requestStop();
395
+ }
351
396
  agent.killProcess();
352
397
  }
353
398
  if (this.server) {
399
+ console.log('[Shutdown] Closing HTTP server');
354
400
  this.server.close();
355
401
  }
356
402
  }
@@ -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;
@@ -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 };