agent-tank 0.9.1 → 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.1",
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 {
@@ -11,6 +12,7 @@ class BaseAgent {
11
12
  this._metadataFetched = false;
12
13
  this.lastUpdated = null;
13
14
  this.error = null;
15
+ this.auth = null;
14
16
  this.isRefreshing = false;
15
17
  this.refreshInterval = null; // Per-agent override (seconds), null = use global
16
18
  this.minRefreshInterval = null; // Per-agent minimum (seconds), null = no minimum
@@ -23,6 +25,22 @@ class BaseAgent {
23
25
  this._onDataCallback = null;
24
26
  this._commandInFlight = false;
25
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;
26
44
  }
27
45
 
28
46
  getStatus() {
@@ -32,11 +50,17 @@ class BaseAgent {
32
50
  metadata: this.metadata,
33
51
  lastUpdated: this.lastUpdated,
34
52
  error: this.error,
53
+ auth: this.auth,
35
54
  isRefreshing: this.isRefreshing,
36
55
  };
37
56
  }
38
57
 
39
58
  async refresh() {
59
+ if (this.isStopping()) {
60
+ logger.agent(this.name, 'Skipping refresh while stopping');
61
+ return;
62
+ }
63
+
40
64
  if (this.isRefreshing) {
41
65
  logger.agent(this.name, 'Already refreshing, skipping...');
42
66
  return;
@@ -45,6 +69,7 @@ class BaseAgent {
45
69
  logger.agent(this.name, 'Starting refresh...');
46
70
  this.isRefreshing = true;
47
71
  this.error = null;
72
+ this.auth = null;
48
73
 
49
74
  try {
50
75
  // Fetch metadata once on first refresh (if agent supports it)
@@ -65,12 +90,25 @@ class BaseAgent {
65
90
  }
66
91
  }
67
92
 
93
+ if (this.isStopping()) {
94
+ throw this._createStopError();
95
+ }
96
+
68
97
  const output = await this.runCommand();
69
98
  logger.agent(this.name, 'Got output, length:', logger.dim(`${output.length} chars`));
70
99
 
71
100
  // Check for rate limit and session errors before parsing
72
101
  // Match actual error messages, not incidental mentions like "rate limits and credits"
73
102
  const cleanOutput = this.stripAnsi(output);
103
+ const authState = this.detectAuthenticationState ? this.detectAuthenticationState(cleanOutput) : null;
104
+ if (authState) {
105
+ logger.agent(this.name, 'Authentication issue detected:', logger.json(authState));
106
+ this.auth = authState;
107
+ this.error = this.usage ? `${authState.message} — using cached data` : authState.message;
108
+ this.lastUpdated = new Date().toISOString();
109
+ return;
110
+ }
111
+
74
112
  if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
75
113
  logger.agent(this.name, 'Rate limited, preserving last known usage data');
76
114
  this.error = 'Rate limited — using cached data';
@@ -96,6 +134,10 @@ class BaseAgent {
96
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(); }
97
135
  logger.agent(this.name, 'Parsed usage:', logger.json(this.usage));
98
136
  } catch (err) {
137
+ if (this.isStopping() && err.message === 'Agent stopping') {
138
+ logger.agent(this.name, 'Refresh cancelled during shutdown');
139
+ return;
140
+ }
99
141
  logger.error(`[${this.name}] Error during refresh:`, err.message);
100
142
  this.error = err.message;
101
143
  } finally {
@@ -105,6 +147,10 @@ class BaseAgent {
105
147
  }
106
148
 
107
149
  async runCommand() {
150
+ if (this.isStopping()) {
151
+ throw this._createStopError();
152
+ }
153
+
108
154
  if (this.freshProcess) {
109
155
  return this._runCommandFresh();
110
156
  }
@@ -118,6 +164,10 @@ class BaseAgent {
118
164
  }
119
165
 
120
166
  async spawnProcess() {
167
+ if (this.isStopping()) {
168
+ throw this._createStopError();
169
+ }
170
+
121
171
  return new Promise((resolve, reject) => {
122
172
  let trustHandled = false;
123
173
  let spawnOutput = '';
@@ -133,6 +183,9 @@ class BaseAgent {
133
183
  cwd: '/tmp',
134
184
  env,
135
185
  });
186
+ if (typeof this.shell.pid === 'number') {
187
+ logger.agent(this.name, 'Persistent process pid:', logger.dim(String(this.shell.pid)));
188
+ }
136
189
  } catch (spawnErr) {
137
190
  logger.error(`[${this.name}] Failed to spawn:`, spawnErr.message);
138
191
  reject(new Error(`Failed to spawn ${this.command}: ${spawnErr.message}`));
@@ -140,6 +193,10 @@ class BaseAgent {
140
193
  }
141
194
 
142
195
  const timer = setTimeout(() => {
196
+ if (this.isStopping()) {
197
+ reject(this._createStopError());
198
+ return;
199
+ }
143
200
  logger.error(`[${this.name}] Spawn timeout after ${this.getTimeout()}ms`);
144
201
  this.killProcess();
145
202
  reject(new Error('Timeout waiting for process to become ready'));
@@ -147,6 +204,15 @@ class BaseAgent {
147
204
 
148
205
  let trustCooldownUntil = 0;
149
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
+
150
216
  spawnOutput += data;
151
217
 
152
218
  // Handle trust/update prompts during spawn (only once each)
@@ -159,6 +225,20 @@ class BaseAgent {
159
225
  this._handleAdditionalPrompts(this.shell, data, spawnOutput);
160
226
  this._respondToTerminalQueries(data);
161
227
 
228
+ const authState = this.detectAuthenticationState ? this.detectAuthenticationState(spawnOutput) : null;
229
+ if (authState) {
230
+ logger.agent(this.name, 'Authentication issue detected during spawn');
231
+ clearTimeout(timer);
232
+ spawnDataHandler.dispose();
233
+ spawnExitHandler.dispose();
234
+ this.processReady = true;
235
+ this.output = spawnOutput;
236
+ this._setupPersistentDataHandler();
237
+ this._setupPersistentExitHandler();
238
+ resolve();
239
+ return;
240
+ }
241
+
162
242
  if (Date.now() >= trustCooldownUntil && this.isReadyForCommands(spawnOutput)) {
163
243
  logger.agent(this.name, 'Process ready for commands');
164
244
  clearTimeout(timer);
@@ -176,13 +256,29 @@ class BaseAgent {
176
256
  spawnExitHandler.dispose();
177
257
  this.shell = null;
178
258
  this.processReady = false;
259
+ if (this.isStopping()) {
260
+ reject(this._createStopError());
261
+ return;
262
+ }
179
263
  reject(new Error(`Process exited during spawn with code ${exitCode}`));
180
264
  });
181
265
  });
182
266
  }
183
267
 
184
268
  async sendCommandAndWait() {
269
+ if (this.isStopping()) {
270
+ throw this._createStopError();
271
+ }
272
+
185
273
  return new Promise((resolve, reject) => {
274
+ const existingOutput = this.output;
275
+ const existingAuthState = this.detectAuthenticationState ? this.detectAuthenticationState(existingOutput) : null;
276
+ if (existingAuthState) {
277
+ logger.agent(this.name, 'Using existing authentication output without sending commands');
278
+ resolve(existingOutput);
279
+ return;
280
+ }
281
+
186
282
  this.output = '';
187
283
  this._commandInFlight = true;
188
284
 
@@ -194,6 +290,12 @@ class BaseAgent {
194
290
  };
195
291
 
196
292
  const timer = setTimeout(() => {
293
+ if (this.isStopping()) {
294
+ this._commandInFlight = false;
295
+ this._onDataCallback = null;
296
+ reject(this._createStopError());
297
+ return;
298
+ }
197
299
  logger.agent(this.name, 'Command timeout after', logger.dim(`${this.getTimeout()}ms`), ', output length:', logger.dim(`${this.output.length}`));
198
300
  if (this.output.length > 0) {
199
301
  logger.agent(this.name, 'Partial output:', logger.dim(this.stripAnsi(this.output).substring(0, 500)));
@@ -211,6 +313,13 @@ class BaseAgent {
211
313
  }, this.getTimeout());
212
314
 
213
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
+ }
214
323
  if (this.hasCompleteOutput(this.output)) {
215
324
  logger.agent(this.name, 'Complete output detected');
216
325
  // Small delay to capture any remaining output
@@ -264,15 +373,27 @@ class BaseAgent {
264
373
  _handleAdditionalPrompts(_s, _d, _o) { } // Hook for subclasses; killProcess: see below
265
374
  killProcess() { // Terminates the persistent PTY process
266
375
  if (this.shell) {
267
- 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;
268
379
  for (const d of this._disposables) { d.dispose(); }
269
380
  this._disposables = []; this._onDataCallback = null; this.processReady = false;
270
- 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 */ }
271
388
  this.shell = null;
272
389
  }
273
390
  }
274
391
 
275
392
  async _runCommandFresh() {
393
+ if (this.isStopping()) {
394
+ throw this._createStopError();
395
+ }
396
+
276
397
  return new Promise((resolve, reject) => {
277
398
  const timeout = this.getTimeout();
278
399
  let output = '';
@@ -300,6 +421,12 @@ class BaseAgent {
300
421
 
301
422
  const timer = setTimeout(() => {
302
423
  if (!completed) {
424
+ if (this.isStopping()) {
425
+ completed = true;
426
+ shell.kill();
427
+ reject(this._createStopError());
428
+ return;
429
+ }
303
430
  completed = true;
304
431
  logger.agent(this.name, 'Timeout after', logger.dim(`${timeout}ms`), ', output length:', logger.dim(`${output.length}`));
305
432
  if (output.length > 0) {
@@ -318,6 +445,16 @@ class BaseAgent {
318
445
 
319
446
  let trustCooldownUntil = 0;
320
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
+
321
458
  output += data;
322
459
 
323
460
  if (output.length <= data.length) {
@@ -361,6 +498,10 @@ class BaseAgent {
361
498
  if (!completed) {
362
499
  completed = true;
363
500
  clearTimeout(timer);
501
+ if (this.isStopping()) {
502
+ reject(this._createStopError());
503
+ return;
504
+ }
364
505
  logger.agent(this.name, 'Process exited with code', logger.dim(exitCode), ', output length:', logger.dim(`${output.length}`));
365
506
  if (output) {
366
507
  resolve(output);
@@ -380,6 +521,7 @@ class BaseAgent {
380
521
 
381
522
  /** Lightweight keepalive method to prevent session expiration. Subclasses can override. @returns {Promise<boolean>} True if keepalive succeeded */
382
523
  async keepalive() {
524
+ if (this.isStopping()) { return false; }
383
525
  if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
384
526
  if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
385
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}`));
@@ -10,10 +10,24 @@ class GeminiAgent extends BaseAgent {
10
10
  }
11
11
 
12
12
  getTimeout() {
13
- return 25000; // 25 seconds
13
+ return 35000; // 35 seconds - Gemini can take 10+ seconds to start
14
+ }
15
+
16
+ getEnv() {
17
+ // Use xterm-256color for proper terminal support
18
+ // Gemini CLI needs good terminal emulation for its TUI
19
+ return {
20
+ ...process.env,
21
+ TERM: 'xterm-256color',
22
+ COLORTERM: 'truecolor',
23
+ };
14
24
  }
15
25
 
16
26
  isReadyForCommands(output) {
27
+ // Don't consider ready if authentication is in progress
28
+ if (this._isAuthenticating(output)) {
29
+ return false;
30
+ }
17
31
  // v0.24: "Type your message" prompt
18
32
  // v0.35+: "Ready" in terminal title + visible prompt area (no "Type your message")
19
33
  // Also check for terminal title sequence containing "Ready"
@@ -22,6 +36,52 @@ class GeminiAgent extends BaseAgent {
22
36
  || /Ready\s*\(/.test(output);
23
37
  }
24
38
 
39
+ _isAuthenticating(output) {
40
+ // Detect OAuth/authentication screens where Escape would cancel the flow
41
+ const clean = this.stripAnsi(output);
42
+
43
+ // If we see the ready prompt, auth is complete (even if auth text still in buffer)
44
+ if (clean.includes('Type your message') || clean.includes('? for shortcuts')) {
45
+ return false;
46
+ }
47
+
48
+ // Only consider authenticating if we see auth-specific text
49
+ return /waiting for authentication/i.test(clean)
50
+ || /press esc.*to cancel/i.test(clean)
51
+ || /authenticating\.\.\./i.test(clean);
52
+ }
53
+
54
+ detectAuthenticationState(output) {
55
+ const clean = this.stripAnsi(output);
56
+ if (!clean) return null;
57
+
58
+ const hasAuthMenu = /how would you like to authenticate for this project\?/i.test(clean)
59
+ || /get started/i.test(clean);
60
+ const hasAuthFailure = /failed to sign in\.\s*message:\s*([^\n]+)/i.test(clean)
61
+ || /please run gemini cli in an interactive terminal to authenticate/i.test(clean)
62
+ || /authentication consent could not be obtained/i.test(clean);
63
+
64
+ if (!hasAuthMenu && !hasAuthFailure) {
65
+ return null;
66
+ }
67
+
68
+ const failureMatch = clean.match(/failed to sign in\.\s*message:\s*([^\n]+)/i);
69
+ const detail = failureMatch
70
+ ? `Failed to sign in: ${failureMatch[1].trim()}`
71
+ : 'Gemini CLI is waiting for authentication.';
72
+ const action = /NO_BROWSER=true/i.test(clean)
73
+ ? 'Run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.'
74
+ : 'Run Gemini CLI in an interactive terminal to authenticate.';
75
+
76
+ return {
77
+ authenticated: false,
78
+ status: 'unauthenticated',
79
+ message: 'Authentication required',
80
+ detail,
81
+ action,
82
+ };
83
+ }
84
+
25
85
  handleTrustPrompt(shell, output) {
26
86
  // Check for trust prompt with various wordings
27
87
  const trustPatterns = [
@@ -42,6 +102,7 @@ class GeminiAgent extends BaseAgent {
42
102
  // v0.35+ does an internal restart after trust — send Escape after a delay
43
103
  // to dismiss any transitional UI before the process becomes ready again
44
104
  setTimeout(() => {
105
+ if (!shell) return;
45
106
  logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
46
107
  shell.write('\x1b');
47
108
  }, 3000);
@@ -55,6 +116,7 @@ class GeminiAgent extends BaseAgent {
55
116
  // Gemini shows "Usage limits" after the model usage table (v0.24.x)
56
117
  if (output.includes('Usage limits span all sessions')) return true;
57
118
  const clean = this.stripAnsi(output);
119
+ if (this.detectAuthenticationState(clean)) return true;
58
120
  const hasModel = /gemini-[\w.-]+/i.test(clean);
59
121
  const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
60
122
  // v0.24: "Resets in 3h 26m", v0.35+: "(23h 21m)" after time
@@ -89,10 +151,16 @@ class GeminiAgent extends BaseAgent {
89
151
  return totalSeconds > 0 ? totalSeconds : null;
90
152
  }
91
153
 
92
- sendCommands(shell, _output) {
154
+ sendCommands(shell, output) {
155
+ // Check if authentication is still in progress - don't send commands that could interfere
156
+ if (this._isAuthenticating(output) || this.detectAuthenticationState(output)) {
157
+ logger.agent(this.name, 'Authentication required or in progress, cannot send /stats command');
158
+ // Don't retry - let the timeout handle it. Sending Escape would cancel auth.
159
+ return;
160
+ }
93
161
  logger.agent(this.name, 'Sending /stats command...');
94
162
  // v0.35+: typing /stats triggers autocomplete dropdown.
95
- // Escape dismisses autocomplete, then Enter submits the command.
163
+ // Escape clears any pending state, then send command, Escape again to dismiss autocomplete
96
164
  setTimeout(() => shell.write('\x1b'), 50);
97
165
  setTimeout(() => shell.write('/stats'), 500);
98
166
  setTimeout(() => shell.write('\x1b'), 1000);
@@ -161,6 +229,10 @@ class GeminiAgent extends BaseAgent {
161
229
  await this.spawnProcess();
162
230
  }
163
231
 
232
+ if (this.detectAuthenticationState(this.output)) {
233
+ return null;
234
+ }
235
+
164
236
  return new Promise((resolve, _reject) => {
165
237
  let aboutOutput = '';
166
238
  let completed = false;
@@ -181,8 +253,16 @@ class GeminiAgent extends BaseAgent {
181
253
  }, 10000); // 10 second timeout for /about
182
254
 
183
255
  this._commandInFlight = true;
256
+ let trustHandled = false;
184
257
  this._onDataCallback = () => {
185
258
  aboutOutput = this.output;
259
+ // Handle trust prompts that appear during command execution
260
+ if (!trustHandled && this.handleTrustPrompt(this.shell, aboutOutput)) {
261
+ trustHandled = true;
262
+ aboutOutput = '';
263
+ this.output = '';
264
+ return;
265
+ }
186
266
  // Check if we have complete /about output
187
267
  if (this._hasCompleteAboutOutput(aboutOutput)) {
188
268
  logger.agent(this.name, 'Complete /about output detected');
@@ -192,11 +272,39 @@ class GeminiAgent extends BaseAgent {
192
272
 
193
273
  logger.agent(this.name, 'Sending /about command for metadata...');
194
274
  this.output = '';
195
- // Escape clears pending state, delay for CLI to be fully interactive,
196
- // second Enter in case /about has an autocomplete menu in newer versions
197
- setTimeout(() => this.shell.write('\x1b'), 100);
198
- setTimeout(() => this.shell.write('/about\r'), 2000);
199
- setTimeout(() => this.shell.write('\r'), 2500);
275
+
276
+ // Wait for authentication to complete before sending commands
277
+ // Auth can start after spawn if token refresh is needed
278
+ // Gemini can take 10+ seconds to fully start up
279
+ let authWaitCount = 0;
280
+ const maxAuthWait = 15; // Max 15 seconds waiting for auth
281
+
282
+ const waitForAuth = () => {
283
+ if (this.detectAuthenticationState(this.output)) {
284
+ logger.agent(this.name, 'Authentication required, skipping /about metadata fetch');
285
+ finish(null);
286
+ return;
287
+ }
288
+ if (this._isAuthenticating(this.output)) {
289
+ authWaitCount++;
290
+ if (authWaitCount >= maxAuthWait) {
291
+ logger.agent(this.name, 'Authentication timeout - Gemini may be stuck in auth flow');
292
+ // Don't send commands that would interfere with stuck auth
293
+ // Let the overall timeout handle this
294
+ return;
295
+ }
296
+ logger.agent(this.name, 'Authentication in progress, waiting...');
297
+ setTimeout(waitForAuth, 1000);
298
+ return;
299
+ }
300
+ // Auth complete (or not happening), send commands
301
+ // Guard against shell being nullified if process exits during setTimeout delays
302
+ // Don't send Escape first - it can cancel auth flows that started between checks
303
+ setTimeout(() => this.shell?.write('/about\r'), 500);
304
+ setTimeout(() => this.shell?.write('\r'), 1000);
305
+ };
306
+
307
+ setTimeout(waitForAuth, 100);
200
308
  });
201
309
  }
202
310
 
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 };
@@ -516,6 +516,21 @@ body.light-mode .progress-bar {
516
516
  font-size: 13px;
517
517
  }
518
518
 
519
+ .auth-notice {
520
+ font-size: 13px;
521
+ color: #f6ad55;
522
+ padding: 8px 10px;
523
+ margin-bottom: 10px;
524
+ background: rgba(246, 173, 85, 0.08);
525
+ border: 1px solid rgba(246, 173, 85, 0.2);
526
+ border-radius: 4px;
527
+ }
528
+
529
+ .auth-detail {
530
+ margin-top: 4px;
531
+ color: var(--text-secondary);
532
+ }
533
+
519
534
  /* Version update notice below agent cards */
520
535
  .version-update-notice {
521
536
  font-family: var(--font-mono);
@@ -533,6 +548,12 @@ body.light-mode .version-update-notice {
533
548
  border-color: rgba(183, 121, 31, 0.2);
534
549
  }
535
550
 
551
+ body.light-mode .auth-notice {
552
+ color: #b7791f;
553
+ background: rgba(246, 173, 85, 0.12);
554
+ border-color: rgba(183, 121, 31, 0.2);
555
+ }
556
+
536
557
  button {
537
558
  width: 100%;
538
559
  padding: 10px;
@@ -9,6 +9,15 @@ const { getStatusBadgeClass, getStatusText } = require('./public-status');
9
9
 
10
10
  const styles = fs.readFileSync(path.join(__dirname, 'status-page.css'), 'utf8');
11
11
 
12
+ function escapeHtml(value) {
13
+ return String(value)
14
+ .replace(/&/g, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
19
+ }
20
+
12
21
  function statusPage(status) {
13
22
  const agents = Object.entries(status);
14
23
 
@@ -36,7 +45,7 @@ function statusPage(status) {
36
45
  const publicStatus = data.publicStatus;
37
46
  const badgeClass = publicStatus ? getStatusBadgeClass(publicStatus.status) : 'status-badge-grey';
38
47
  const badgeText = publicStatus ? getStatusText(publicStatus.status) : 'Unknown';
39
- const badgeTitle = publicStatus?.description || 'Unable to fetch status';
48
+ const badgeTitle = escapeHtml(publicStatus?.description || 'Unable to fetch status');
40
49
  const statusBadgeHtml = `<span class="status-badge ${badgeClass}" title="${badgeTitle}">${badgeText}</span>`;
41
50
 
42
51
  // Version update notice - check usage.version (Codex) or metadata.updateAvailable (Gemini)
@@ -45,10 +54,15 @@ function statusPage(status) {
45
54
  ? `<div class="version-update-notice">Update available: v${version.current} → v${version.latest}</div>`
46
55
  : '';
47
56
 
57
+ const authHtml = data.auth
58
+ ? `<div class="auth-notice">${escapeHtml(data.auth.action || 'Please log in via the CLI.')}</div>`
59
+ : '';
60
+ const errorHtml = data.error && !data.auth ? `<span class="error-msg">${escapeHtml(data.error)}</span>` : '';
61
+
48
62
  // Only show card-footer if there's an error or update notice
49
- const hasFooterContent = data.error || updateHtml;
63
+ const hasFooterContent = errorHtml || authHtml || updateHtml;
50
64
  const footerHtml = hasFooterContent
51
- ? `<div class="card-footer">${data.error ? `<span class="error-msg">${data.error}</span>` : ''}${updateHtml}</div>`
65
+ ? `<div class="card-footer">${errorHtml}${authHtml}${updateHtml}</div>`
52
66
  : '';
53
67
 
54
68
  return `