agent-tank 0.9.1 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tank",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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": {
@@ -11,6 +11,7 @@ class BaseAgent {
11
11
  this._metadataFetched = false;
12
12
  this.lastUpdated = null;
13
13
  this.error = null;
14
+ this.auth = null;
14
15
  this.isRefreshing = false;
15
16
  this.refreshInterval = null; // Per-agent override (seconds), null = use global
16
17
  this.minRefreshInterval = null; // Per-agent minimum (seconds), null = no minimum
@@ -32,6 +33,7 @@ class BaseAgent {
32
33
  metadata: this.metadata,
33
34
  lastUpdated: this.lastUpdated,
34
35
  error: this.error,
36
+ auth: this.auth,
35
37
  isRefreshing: this.isRefreshing,
36
38
  };
37
39
  }
@@ -45,6 +47,7 @@ class BaseAgent {
45
47
  logger.agent(this.name, 'Starting refresh...');
46
48
  this.isRefreshing = true;
47
49
  this.error = null;
50
+ this.auth = null;
48
51
 
49
52
  try {
50
53
  // Fetch metadata once on first refresh (if agent supports it)
@@ -71,6 +74,15 @@ class BaseAgent {
71
74
  // Check for rate limit and session errors before parsing
72
75
  // Match actual error messages, not incidental mentions like "rate limits and credits"
73
76
  const cleanOutput = this.stripAnsi(output);
77
+ const authState = this.detectAuthenticationState ? this.detectAuthenticationState(cleanOutput) : null;
78
+ if (authState) {
79
+ logger.agent(this.name, 'Authentication issue detected:', logger.json(authState));
80
+ this.auth = authState;
81
+ this.error = this.usage ? `${authState.message} — using cached data` : authState.message;
82
+ this.lastUpdated = new Date().toISOString();
83
+ return;
84
+ }
85
+
74
86
  if (/rate.?limited|rate_limit_error/i.test(cleanOutput)) {
75
87
  logger.agent(this.name, 'Rate limited, preserving last known usage data');
76
88
  this.error = 'Rate limited — using cached data';
@@ -159,6 +171,20 @@ class BaseAgent {
159
171
  this._handleAdditionalPrompts(this.shell, data, spawnOutput);
160
172
  this._respondToTerminalQueries(data);
161
173
 
174
+ const authState = this.detectAuthenticationState ? this.detectAuthenticationState(spawnOutput) : null;
175
+ if (authState) {
176
+ logger.agent(this.name, 'Authentication issue detected during spawn');
177
+ clearTimeout(timer);
178
+ spawnDataHandler.dispose();
179
+ spawnExitHandler.dispose();
180
+ this.processReady = true;
181
+ this.output = spawnOutput;
182
+ this._setupPersistentDataHandler();
183
+ this._setupPersistentExitHandler();
184
+ resolve();
185
+ return;
186
+ }
187
+
162
188
  if (Date.now() >= trustCooldownUntil && this.isReadyForCommands(spawnOutput)) {
163
189
  logger.agent(this.name, 'Process ready for commands');
164
190
  clearTimeout(timer);
@@ -183,6 +209,14 @@ class BaseAgent {
183
209
 
184
210
  async sendCommandAndWait() {
185
211
  return new Promise((resolve, reject) => {
212
+ const existingOutput = this.output;
213
+ const existingAuthState = this.detectAuthenticationState ? this.detectAuthenticationState(existingOutput) : null;
214
+ if (existingAuthState) {
215
+ logger.agent(this.name, 'Using existing authentication output without sending commands');
216
+ resolve(existingOutput);
217
+ return;
218
+ }
219
+
186
220
  this.output = '';
187
221
  this._commandInFlight = true;
188
222
 
@@ -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
 
@@ -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, '&')
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 `