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/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 },
@@ -11,12 +13,14 @@ const options = {
11
13
  'claude-api': { type: 'boolean', default: false },
12
14
  port: { type: 'string', default: '3456' },
13
15
  host: { type: 'string' },
16
+ docker: { type: 'boolean', default: true },
14
17
  'auth-user': { type: 'string' },
15
18
  'auth-pass': { type: 'string' },
16
19
  'auth-token': { type: 'string' },
17
20
  'fresh-process': { type: 'boolean', default: false },
18
21
  config: { type: 'string', short: 'c' },
19
22
  help: { type: 'boolean', short: 'h', default: false },
23
+ version: { type: 'boolean', short: 'v', default: false },
20
24
  'auto-discover': { type: 'boolean', default: true },
21
25
  'auto-refresh': { type: 'boolean', default: true },
22
26
  'auto-refresh-interval': { type: 'string', default: '60' },
@@ -50,12 +54,14 @@ Options:
50
54
  --codex Enable Codex monitoring
51
55
  --claude-api Use direct Anthropic API for Claude usage (faster, 60s refresh)
52
56
  --port <port> HTTP server port (default: 3456)
53
- --host <host> Bind address (default: 127.0.0.1)
57
+ --host <host> Bind address (default: 127.0.0.1 + Docker bridge when available)
58
+ --docker Enable Docker bridge bind when --host is omitted (default: true)
54
59
  --auth-user <user> HTTP Basic Auth username
55
60
  --auth-pass <pass> HTTP Basic Auth password
56
61
  --auth-token <token> API key for Bearer token auth
57
62
  --fresh-process Spawn a new process per refresh (default: false)
58
63
  --config, -c Path to config file (JSON)
64
+ --version, -v Show version
59
65
  --auto-discover Auto-discover available agents (default: true)
60
66
  --auto-refresh Enable/disable background auto-refresh (default: true)
61
67
  --auto-refresh-mode <mode> Refresh mode: none, interval, activity (default: activity)
@@ -79,6 +85,7 @@ Environment variables:
79
85
  AGENT_TANK_PASS Basic auth password (overrides --auth-pass)
80
86
  AGENT_TANK_TOKEN API key (overrides --auth-token)
81
87
  AGENT_TANK_HOST Bind address (overrides --host)
88
+ AGENT_TANK_DOCKER Enable/disable Docker bridge bind ("1"/"true" or "0"/"false")
82
89
  AGENT_TANK_FRESH_PROCESS Use fresh process per refresh ("1" or "true")
83
90
  AGENT_TANK_CLAUDE_API Use direct Anthropic API for Claude usage ("1" or "true")
84
91
  AGENT_TANK_AUTO_REFRESH Enable/disable background auto-refresh ("1" or "true" / "0" or "false")
@@ -94,6 +101,7 @@ Examples:
94
101
  agent-tank --claude --gemini # Monitor specific agents
95
102
  agent-tank --port 8080 # Use custom port
96
103
  agent-tank --host 0.0.0.0 # Expose on all interfaces
104
+ agent-tank --no-docker # Bind localhost only when host is omitted
97
105
  agent-tank --auth-user admin --auth-pass secret # Enable basic auth
98
106
  agent-tank --auth-token mykey # Enable API key auth
99
107
  agent-tank -c ./config.json # Use config file
@@ -127,6 +135,12 @@ async function main() {
127
135
  return;
128
136
  }
129
137
 
138
+ if (values.version) {
139
+ process.stdout.write(`${pkg.version}\n`);
140
+ process.exitCode = 0;
141
+ return;
142
+ }
143
+
130
144
  // Load config file if specified
131
145
  let config = {};
132
146
  if (values.config) {
@@ -151,6 +165,13 @@ async function main() {
151
165
  };
152
166
 
153
167
  const host = process.env.AGENT_TANK_HOST || values.host || config.host;
168
+ const dockerEnv = process.env.AGENT_TANK_DOCKER;
169
+ let dockerAccess = values.docker;
170
+ if (dockerEnv !== undefined) {
171
+ dockerAccess = dockerEnv === '1' || dockerEnv === 'true';
172
+ } else if (config.dockerAccess !== undefined) {
173
+ dockerAccess = config.dockerAccess;
174
+ }
154
175
 
155
176
  const freshProcessEnv = process.env.AGENT_TANK_FRESH_PROCESS;
156
177
  const freshProcess = values['fresh-process'] ||
@@ -241,6 +262,7 @@ async function main() {
241
262
  autoDiscover: values['auto-discover'] && agents.length === 0,
242
263
  port: parseInt(values.port || config.port || '3456', 10),
243
264
  host,
265
+ dockerAccess,
244
266
  auth,
245
267
  freshProcess,
246
268
  claudeApi,
@@ -255,15 +277,19 @@ async function main() {
255
277
  });
256
278
 
257
279
  // Graceful shutdown
258
- const shutdown = () => {
280
+ let shuttingDown = false;
281
+ let cleanupShutdownHandlers = () => {};
282
+ const shutdown = (signal) => {
283
+ if (shuttingDown) return;
284
+ shuttingDown = true;
285
+ cleanupShutdownHandlers();
259
286
  if (!jsonMode) {
260
- originalLog('\nShutting down...');
287
+ originalLog(`\nShutting down${signal ? ` (${signal})` : ''}...`);
261
288
  }
262
289
  watcher.stop();
263
- process.exitCode = 0;
290
+ process.exit(0);
264
291
  };
265
- process.on('SIGINT', shutdown);
266
- process.on('SIGTERM', shutdown);
292
+ cleanupShutdownHandlers = installShutdownHandlers({ shutdown });
267
293
 
268
294
  try {
269
295
  if (onceMode) {
@@ -281,6 +307,7 @@ async function main() {
281
307
  originalLog(JSON.stringify(agentStatus, null, 2));
282
308
  }
283
309
  }
310
+ cleanupShutdownHandlers();
284
311
  watcher.stop();
285
312
  process.exitCode = 0;
286
313
  return;
@@ -288,6 +315,7 @@ async function main() {
288
315
 
289
316
  await watcher.start();
290
317
  } catch (err) {
318
+ cleanupShutdownHandlers();
291
319
  if (jsonMode) {
292
320
  console.log = originalLog;
293
321
  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.4",
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; }
@@ -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
@@ -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}`));
@@ -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 = '';