agent-tank 0.9.5 → 0.9.7

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.
@@ -1,412 +0,0 @@
1
- const { BaseAgent } = require('./base.js');
2
- const { calculatePace } = require('../pace-evaluator.js');
3
- const { CYCLE_DURATIONS } = require('../usage-formatters.js');
4
- const logger = require('../logger.js');
5
-
6
- class GeminiAgent extends BaseAgent {
7
- constructor() {
8
- super('gemini', 'gemini');
9
- this._aboutSent = false;
10
- }
11
-
12
- getTimeout() {
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
- };
24
- }
25
-
26
- _hasReadyPrompt(output) {
27
- const clean = this.stripAnsi(output);
28
- return clean.includes('Type your message')
29
- || clean.includes('gemini>')
30
- || /Ready\s*\(/.test(clean);
31
- }
32
-
33
- isReadyForCommands(output) {
34
- // Don't consider ready if authentication is in progress
35
- if (this._isAuthenticating(output)) {
36
- return false;
37
- }
38
- return this._hasReadyPrompt(output);
39
- }
40
-
41
- _isAuthenticating(output) {
42
- // Detect OAuth/authentication screens where Escape would cancel the flow
43
- const clean = this.stripAnsi(output);
44
- const hasAuthFlow = /waiting for authentication/i.test(clean)
45
- || /press esc.*to cancel/i.test(clean)
46
- || /authenticating\.\.\./i.test(clean);
47
-
48
- if (!hasAuthFlow) {
49
- return false;
50
- }
51
-
52
- // Newer Gemini builds can render footer hints like "? for shortcuts"
53
- // before the actual input prompt is available. Treat auth as complete
54
- // only when the real prompt has returned.
55
- return !this._hasReadyPrompt(clean);
56
- }
57
-
58
- detectAuthenticationState(output) {
59
- const clean = this.stripAnsi(output);
60
- if (!clean) return null;
61
-
62
- const hasAuthMenu = /how would you like to authenticate for this project\?/i.test(clean)
63
- || /get started/i.test(clean);
64
- const hasAuthFailure = /failed to sign in\.\s*message:\s*([^\n]+)/i.test(clean)
65
- || /please run gemini cli in an interactive terminal to authenticate/i.test(clean)
66
- || /authentication consent could not be obtained/i.test(clean);
67
-
68
- if (!hasAuthMenu && !hasAuthFailure) {
69
- return null;
70
- }
71
-
72
- const failureMatch = clean.match(/failed to sign in\.\s*message:\s*([^\n]+)/i);
73
- const detail = failureMatch
74
- ? `Failed to sign in: ${failureMatch[1].trim()}`
75
- : 'Gemini CLI is waiting for authentication.';
76
- const action = /NO_BROWSER=true/i.test(clean)
77
- ? 'Run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.'
78
- : 'Run Gemini CLI in an interactive terminal to authenticate.';
79
-
80
- return {
81
- authenticated: false,
82
- status: 'unauthenticated',
83
- message: 'Authentication required',
84
- detail,
85
- action,
86
- };
87
- }
88
-
89
- handleTrustPrompt(shell, output) {
90
- // Check for trust prompt with various wordings
91
- const trustPatterns = [
92
- 'Do you trust',
93
- 'trust the files',
94
- 'trust this folder',
95
- 'Trust this workspace',
96
- 'allow access',
97
- 'grant access',
98
- 'grant full access',
99
- ];
100
-
101
- if (trustPatterns.some(pattern => output.toLowerCase().includes(pattern.toLowerCase()))) {
102
- logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
103
- // v0.35+: numbered menu with "1. Trust folder" pre-selected — Enter accepts it
104
- shell.write('1\r');
105
-
106
- // v0.35+ does an internal restart after trust — send Escape after a delay
107
- // to dismiss any transitional UI before the process becomes ready again
108
- setTimeout(() => {
109
- if (!shell) return;
110
- logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
111
- shell.write('\x1b');
112
- }, 3000);
113
-
114
- return true;
115
- }
116
- return false;
117
- }
118
-
119
- hasCompleteOutput(output) {
120
- // Gemini shows "Usage limits" after the model usage table (v0.24.x)
121
- if (output.includes('Usage limits span all sessions')) return true;
122
- const clean = this.stripAnsi(output);
123
- if (this.detectAuthenticationState(clean)) return true;
124
- const hasModel = /gemini-[\w.-]+/i.test(clean);
125
- const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
126
- // v0.24: "Resets in 3h 26m", v0.35+: "(23h 21m)" after time
127
- const hasResets = /resets?\s+in/i.test(clean) || /\(\d+[hmd]\s+\d+[hmd]?\)/i.test(clean);
128
- return hasModel && hasPercent && hasResets;
129
- }
130
-
131
- // Convert duration string like "3h 26m" or "5d 2h" to seconds
132
- parseDurationToSeconds(durationStr) {
133
- if (!durationStr) return null;
134
-
135
- let totalSeconds = 0;
136
-
137
- // Match days
138
- const daysMatch = durationStr.match(/(\d+)\s*d/i);
139
- if (daysMatch) {
140
- totalSeconds += parseInt(daysMatch[1]) * 24 * 60 * 60;
141
- }
142
-
143
- // Match hours
144
- const hoursMatch = durationStr.match(/(\d+)\s*h/i);
145
- if (hoursMatch) {
146
- totalSeconds += parseInt(hoursMatch[1]) * 60 * 60;
147
- }
148
-
149
- // Match minutes
150
- const minsMatch = durationStr.match(/(\d+)\s*m/i);
151
- if (minsMatch) {
152
- totalSeconds += parseInt(minsMatch[1]) * 60;
153
- }
154
-
155
- return totalSeconds > 0 ? totalSeconds : null;
156
- }
157
-
158
- sendCommands(shell, output) {
159
- // Check if authentication is still in progress - don't send commands that could interfere
160
- if (this._isAuthenticating(output) || this.detectAuthenticationState(output)) {
161
- logger.agent(this.name, 'Authentication required or in progress, cannot send /stats command');
162
- // Don't retry - let the timeout handle it. Sending Escape would cancel auth.
163
- return;
164
- }
165
- logger.agent(this.name, 'Sending /stats command...');
166
- // Newer Gemini builds use Esc for "rewind", which can derail the command flow.
167
- // Submit /stats directly once the prompt is ready.
168
- setTimeout(() => shell.write('/stats'), 200);
169
- setTimeout(() => shell.write('\r'), 600);
170
- }
171
-
172
- // Calculate pace data for a model entry (Gemini uses 24h sessions)
173
- _addPaceData(entry, resetsInSeconds) {
174
- const cycleDuration = CYCLE_DURATIONS.sessionGemini;
175
- if (!cycleDuration || resetsInSeconds == null) return;
176
- const paceData = calculatePace({ usagePercent: entry.percentUsed, resetsInSeconds, cycleDurationSeconds: cycleDuration });
177
- if (paceData) entry.pace = paceData;
178
- }
179
-
180
- parseOutput(output) {
181
- const clean = this.stripAnsi(output);
182
- const usage = {
183
- models: [],
184
- };
185
-
186
- // Parse model usage lines
187
- // Format: "gemini-2.5-flash -90.2% (Resets in 3h 26m)" or "gemini-2.5-flash 90.2% (Resets in 3h 26m)"
188
- const lines = clean.split('\n');
189
-
190
- for (const line of lines) {
191
- // v0.24: "gemini-2.5-flash 90.2% (Resets in 3h 26m)" — percentage is REMAINING
192
- const matchOld = line.match(/(gemini-[\w.-]+)\s+[-\s]*(\d+(?:\.\d+)?)\s*%\s*\(Resets in\s*([^)]+)\)/i);
193
- // v0.35+: "gemini-2.5-flash - ▬▬▬ 6% 10:46 PM (23h 21m)" — percentage is USED
194
- const matchNew = !matchOld && line.match(/(gemini-[\w.-]+)\s+.*?(\d+(?:\.\d+)?)\s*%.*?\((\d+[hmd]\s*\d*[hmd]?)\)/i);
195
- const match = matchOld || matchNew;
196
- if (match) {
197
- const model = match[1];
198
- const rawPercent = parseFloat(match[2]);
199
- const resetsIn = match[3].trim();
200
- const percentUsed = matchOld ? parseFloat((100 - rawPercent).toFixed(1)) : rawPercent;
201
- const resetsInSeconds = this.parseDurationToSeconds(resetsIn);
202
-
203
- // Avoid duplicates
204
- if (!usage.models.find(m => m.model === model)) {
205
- const usageLeft = matchOld ? rawPercent : parseFloat((100 - rawPercent).toFixed(1));
206
- const modelEntry = { model, usageLeft, resetsIn, percentUsed, resetsInSeconds };
207
- this._addPaceData(modelEntry, resetsInSeconds);
208
- usage.models.push(modelEntry);
209
- }
210
- }
211
- }
212
-
213
- // Detect version update notification in output
214
- // Gemini CLI shows: "Update available! 0.24.5 → 0.32.1" or similar
215
- const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
216
- if (updateMatch) {
217
- usage.version = { current: updateMatch[1], latest: updateMatch[2] };
218
- }
219
-
220
- return usage;
221
- }
222
-
223
- // Fetch metadata by sending /about command once on first refresh
224
- async fetchMetadata() {
225
- if (this._aboutSent) {
226
- return this.metadata;
227
- }
228
-
229
- // Ensure process is spawned and ready
230
- if (!this.shell || !this.processReady) {
231
- await this.spawnProcess();
232
- }
233
-
234
- if (this.detectAuthenticationState(this.output)) {
235
- return null;
236
- }
237
-
238
- return new Promise((resolve, _reject) => {
239
- let aboutOutput = '';
240
- let completed = false;
241
-
242
- const finish = (result) => {
243
- if (completed) return;
244
- completed = true;
245
- this._aboutSent = true;
246
- this._onDataCallback = null;
247
- this._commandInFlight = false;
248
- clearTimeout(timer);
249
- resolve(result);
250
- };
251
-
252
- const timer = setTimeout(() => {
253
- logger.agent(this.name, '/about timeout, using partial output');
254
- finish(this._parseAboutOutput(aboutOutput));
255
- }, 10000); // 10 second timeout for /about
256
-
257
- this._commandInFlight = true;
258
- let trustHandled = false;
259
- this._onDataCallback = () => {
260
- aboutOutput = this.output;
261
- // Handle trust prompts that appear during command execution
262
- if (!trustHandled && this.handleTrustPrompt(this.shell, aboutOutput)) {
263
- trustHandled = true;
264
- aboutOutput = '';
265
- this.output = '';
266
- return;
267
- }
268
- // Check if we have complete /about output
269
- if (this._hasCompleteAboutOutput(aboutOutput)) {
270
- logger.agent(this.name, 'Complete /about output detected');
271
- setTimeout(() => finish(this._parseAboutOutput(aboutOutput)), 100);
272
- }
273
- };
274
-
275
- logger.agent(this.name, 'Sending /about command for metadata...');
276
- this.output = '';
277
-
278
- // Wait for authentication to complete before sending commands
279
- // Auth can start after spawn if token refresh is needed
280
- // Gemini can take 10+ seconds to fully start up
281
- let authWaitCount = 0;
282
- const maxAuthWait = 15; // Max 15 seconds waiting for auth
283
-
284
- const waitForAuth = () => {
285
- if (this.detectAuthenticationState(this.output)) {
286
- logger.agent(this.name, 'Authentication required, skipping /about metadata fetch');
287
- finish(null);
288
- return;
289
- }
290
- if (this._isAuthenticating(this.output)) {
291
- authWaitCount++;
292
- if (authWaitCount >= maxAuthWait) {
293
- logger.agent(this.name, 'Authentication timeout - Gemini may be stuck in auth flow');
294
- // Don't send commands that would interfere with stuck auth
295
- // Let the overall timeout handle this
296
- return;
297
- }
298
- logger.agent(this.name, 'Authentication in progress, waiting...');
299
- setTimeout(waitForAuth, 1000);
300
- return;
301
- }
302
- // Auth complete (or not happening), send commands
303
- // Guard against shell being nullified if process exits during setTimeout delays
304
- // Don't send Escape first - it can cancel auth flows that started between checks
305
- setTimeout(() => this.shell?.write('/about\r'), 500);
306
- setTimeout(() => this.shell?.write('\r'), 1000);
307
- };
308
-
309
- setTimeout(waitForAuth, 100);
310
- });
311
- }
312
-
313
- _hasCompleteAboutOutput(output) {
314
- const clean = this.stripAnsi(output);
315
- // Require actual /about content (not just status bar text)
316
- const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
317
- // Require prompt to have returned after the about box
318
- const hasPrompt = clean.includes('Type your message');
319
- return hasAboutContent && hasPrompt;
320
- }
321
-
322
- _parseAboutOutput(output) {
323
- const clean = this.stripAnsi(output);
324
-
325
- // Guard: verify we have actual /about output, not just status bar noise.
326
- // The status bar contains "no sandbox (see /docs) Auto (Gemini 2.5) /model"
327
- // which can false-positive match loose regexes.
328
- const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
329
- if (!hasAboutContent) {
330
- logger.agent(this.name, '/about output missing expected header, dumping for debug');
331
- require('fs').writeFileSync(`/tmp/${this.name}-about-output.txt`, output);
332
- logger.agent(this.name, 'Stripped output preview:', logger.dim(clean.substring(0, 500)));
333
- return null;
334
- }
335
-
336
- const metadata = {};
337
-
338
- // Gemini /about uses whitespace-separated key-value pairs (no colons):
339
- // CLI Version 0.24.5
340
- // OS linux
341
- // Auth Method OAuth
342
- // User Email livecart@gmail.com
343
- // After stripAnsi collapses multi-spaces, these become single-space separated.
344
-
345
- // Extract version: "CLI Version 0.24.5"
346
- const versionMatch = clean.match(/CLI\s+Version[:\s]+v?([\d.]+)/i);
347
- if (versionMatch) {
348
- metadata.version = this.stripBoxChars(versionMatch[1]);
349
- }
350
-
351
- // Extract email: "User Email livecart@gmail.com"
352
- const emailMatch = clean.match(/User\s+Email[:\s]+(\S+@\S+)/i);
353
- if (emailMatch) {
354
- metadata.email = this.stripBoxChars(emailMatch[1]);
355
- }
356
-
357
- // Extract auth method: "Auth Method OAuth"
358
- const authMatch = clean.match(/Auth\s*Method[:\s]+(\w+)/i);
359
- if (authMatch) {
360
- metadata.authMethod = this.stripBoxChars(authMatch[1]);
361
- }
362
-
363
- // Extract model: "Model auto-gemini-2.5" (but NOT "/model" from the status bar)
364
- const modelMatch = clean.match(/(?<!\/)Model[:\s]+([\w.-]+)/i);
365
- if (modelMatch) {
366
- metadata.model = this.stripBoxChars(modelMatch[1]);
367
- }
368
-
369
- // Extract OS: "OS linux"
370
- const osMatch = clean.match(/\bOS[:\s]+(\w+)/i);
371
- if (osMatch) {
372
- metadata.os = this.stripBoxChars(osMatch[1]);
373
- }
374
-
375
- // Detect version update notification
376
- const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
377
- if (updateMatch) {
378
- metadata.updateAvailable = { current: updateMatch[1], latest: updateMatch[2] };
379
- }
380
-
381
- return Object.keys(metadata).length > 0 ? metadata : null;
382
- }
383
-
384
- /**
385
- * Lightweight keepalive to prevent session expiration.
386
- * Sends escape key to maintain the PTY session without triggering API calls.
387
- *
388
- * @returns {Promise<boolean>} True if keepalive succeeded
389
- */
390
- async keepalive() {
391
- if (this.freshProcess) {
392
- console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
393
- return true;
394
- }
395
-
396
- if (!this.shell || !this.processReady) {
397
- console.log(`[${this.name}] Keepalive: spawning process...`);
398
- await this.spawnProcess();
399
- }
400
-
401
- if (this.shell) {
402
- console.log(`[${this.name}] Keepalive: sending ping...`);
403
- // Send escape key to dismiss any UI and trigger activity
404
- this.shell.write('\x1b');
405
- return true;
406
- }
407
-
408
- return false;
409
- }
410
- }
411
-
412
- module.exports = { GeminiAgent };