agent-tank 0.9.6 → 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,234 +0,0 @@
1
- const fs = require('node:fs');
2
- const os = require('node:os');
3
- const path = require('node:path');
4
-
5
- const GEMINI_DIR = '.gemini';
6
- const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9' + 'e3aqf6av3hmdib135j.apps.googleusercontent.com';
7
- const OAUTH_CLIENT_SECRET = 'GOCSPX-4uHgMPm-1o7Sk-' + 'geV6Cu5clXFsxl';
8
- const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
9
- const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal';
10
- const REFRESH_SKEW_MS = 60 * 1000;
11
- const VALID_GEMINI_MODES = new Set(['pty', 'direct', 'fallback']);
12
-
13
- function getGeminiHomeDir() {
14
- return path.join(os.homedir(), GEMINI_DIR);
15
- }
16
-
17
- function getOAuthCredsPath() {
18
- return path.join(getGeminiHomeDir(), 'oauth_creds.json');
19
- }
20
-
21
- function getGoogleAccountsPath() {
22
- return path.join(getGeminiHomeDir(), 'google_accounts.json');
23
- }
24
-
25
- function normalizeGeminiMode(mode) {
26
- const normalized = typeof mode === 'string' ? mode.toLowerCase() : 'fallback';
27
- return VALID_GEMINI_MODES.has(normalized) ? normalized : 'fallback';
28
- }
29
-
30
- function readJsonIfExists(filePath) {
31
- try {
32
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
33
- } catch (err) {
34
- if (err && err.code === 'ENOENT') {
35
- return null;
36
- }
37
- throw err;
38
- }
39
- }
40
-
41
- function formatDurationFromSeconds(totalSeconds) {
42
- if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
43
- return '0m';
44
- }
45
-
46
- const days = Math.floor(totalSeconds / 86400);
47
- const hours = Math.floor((totalSeconds % 86400) / 3600);
48
- const minutes = Math.floor((totalSeconds % 3600) / 60);
49
-
50
- if (days > 0) {
51
- return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
52
- }
53
-
54
- if (hours > 0) {
55
- return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
56
- }
57
-
58
- return `${Math.max(1, minutes)}m`;
59
- }
60
-
61
- function parseResetTime(resetTime) {
62
- if (!resetTime) {
63
- return { resetsIn: null, resetsInSeconds: null };
64
- }
65
-
66
- const resetAtMs = Date.parse(resetTime);
67
- if (!Number.isFinite(resetAtMs)) {
68
- return { resetsIn: null, resetsInSeconds: null };
69
- }
70
-
71
- const resetsInSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
72
- return {
73
- resetsIn: formatDurationFromSeconds(resetsInSeconds),
74
- resetsInSeconds,
75
- };
76
- }
77
-
78
- function buildModelEntry(bucket) {
79
- if (!bucket || !bucket.modelId || bucket.remainingFraction == null) {
80
- return null;
81
- }
82
-
83
- const usageLeft = Number((bucket.remainingFraction * 100).toFixed(1));
84
- const percentUsed = Number((100 - usageLeft).toFixed(1));
85
- const { resetsIn, resetsInSeconds } = parseResetTime(bucket.resetTime);
86
-
87
- return {
88
- model: bucket.modelId,
89
- usageLeft,
90
- percentUsed,
91
- resetsIn,
92
- resetsInSeconds,
93
- };
94
- }
95
-
96
- function buildUsageFromQuota(quotaResponse) {
97
- const seenModels = new Set();
98
- const models = [];
99
-
100
- for (const bucket of quotaResponse?.buckets || []) {
101
- const entry = buildModelEntry(bucket);
102
- if (!entry || seenModels.has(entry.model)) {
103
- continue;
104
- }
105
- seenModels.add(entry.model);
106
- models.push(entry);
107
- }
108
-
109
- return { models };
110
- }
111
-
112
- class GeminiDirectQuotaClient {
113
- constructor(options = {}) {
114
- this.fetchImpl = options.fetchImpl || global.fetch;
115
- this.now = options.now || Date.now;
116
- }
117
-
118
- _readOAuthCredentials() {
119
- const creds = readJsonIfExists(getOAuthCredsPath());
120
- if (!creds) {
121
- throw new Error(`Gemini OAuth credentials not found at ${getOAuthCredsPath()}`);
122
- }
123
- return creds;
124
- }
125
-
126
- _readActiveAccountEmail() {
127
- const accounts = readJsonIfExists(getGoogleAccountsPath());
128
- return accounts?.active || null;
129
- }
130
-
131
- async _getAccessToken() {
132
- const creds = this._readOAuthCredentials();
133
- const expiryDate = Number(creds.expiry_date);
134
- const accessToken = creds.access_token;
135
-
136
- if (accessToken && Number.isFinite(expiryDate) && expiryDate - REFRESH_SKEW_MS > this.now()) {
137
- return accessToken;
138
- }
139
-
140
- if (!creds.refresh_token) {
141
- throw new Error('Gemini OAuth credentials are missing a refresh token');
142
- }
143
-
144
- const body = new URLSearchParams({
145
- client_id: OAUTH_CLIENT_ID,
146
- client_secret: OAUTH_CLIENT_SECRET,
147
- refresh_token: creds.refresh_token,
148
- grant_type: 'refresh_token',
149
- });
150
-
151
- const response = await this.fetchImpl(TOKEN_ENDPOINT, {
152
- method: 'POST',
153
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
- body,
155
- });
156
-
157
- if (!response.ok) {
158
- const text = await response.text();
159
- throw new Error(`Failed to refresh Gemini OAuth token (${response.status}): ${text || response.statusText}`);
160
- }
161
-
162
- const refreshed = await response.json();
163
- const updatedCreds = {
164
- ...creds,
165
- access_token: refreshed.access_token,
166
- expiry_date: refreshed.expires_in ? this.now() + (refreshed.expires_in * 1000) : creds.expiry_date,
167
- token_type: refreshed.token_type || creds.token_type,
168
- scope: refreshed.scope || creds.scope,
169
- };
170
-
171
- fs.writeFileSync(getOAuthCredsPath(), JSON.stringify(updatedCreds, null, 2));
172
- return updatedCreds.access_token;
173
- }
174
-
175
- async _post(method, token, payload) {
176
- const response = await this.fetchImpl(`${CODE_ASSIST_ENDPOINT}:${method}`, {
177
- method: 'POST',
178
- headers: {
179
- 'Content-Type': 'application/json',
180
- Authorization: `Bearer ${token}`,
181
- },
182
- body: JSON.stringify(payload),
183
- });
184
-
185
- if (!response.ok) {
186
- const text = await response.text();
187
- throw new Error(`Gemini ${method} request failed (${response.status}): ${text || response.statusText}`);
188
- }
189
-
190
- return response.json();
191
- }
192
-
193
- async fetchQuotaUsage() {
194
- const token = await this._getAccessToken();
195
- const configuredProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
196
-
197
- const loadResponse = await this._post('loadCodeAssist', token, {
198
- cloudaicompanionProject: configuredProject,
199
- metadata: {
200
- ideType: 'IDE_UNSPECIFIED',
201
- platform: 'PLATFORM_UNSPECIFIED',
202
- pluginType: 'GEMINI',
203
- duetProject: configuredProject,
204
- },
205
- mode: 'HEALTH_CHECK',
206
- });
207
-
208
- const project = loadResponse?.cloudaicompanionProject || configuredProject;
209
- if (!project) {
210
- throw new Error('Gemini Code Assist project ID is unavailable');
211
- }
212
-
213
- const quotaResponse = await this._post('retrieveUserQuota', token, { project });
214
- const usage = buildUsageFromQuota(quotaResponse);
215
-
216
- return {
217
- usage,
218
- metadata: {
219
- email: this._readActiveAccountEmail(),
220
- authMethod: 'OAuth',
221
- tier: loadResponse?.paidTier?.name || loadResponse?.currentTier?.name,
222
- project,
223
- },
224
- };
225
- }
226
- }
227
-
228
- module.exports = {
229
- GeminiDirectQuotaClient,
230
- buildModelEntry,
231
- buildUsageFromQuota,
232
- formatDurationFromSeconds,
233
- normalizeGeminiMode,
234
- };
@@ -1,463 +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 { GeminiDirectQuotaClient, normalizeGeminiMode } = require('./gemini-direct.js');
5
- const logger = require('../logger.js');
6
-
7
- class GeminiAgent extends BaseAgent {
8
- constructor(options = {}) {
9
- super('gemini', 'gemini', ['--skip-trust']);
10
- this.mode = normalizeGeminiMode(options.mode);
11
- this.directClient = new GeminiDirectQuotaClient(options.directClientOptions);
12
- this._aboutSent = false;
13
- }
14
-
15
- async refresh() {
16
- if (this.mode === 'pty') {
17
- return super.refresh();
18
- }
19
-
20
- if (this.isStopping()) {
21
- logger.agent(this.name, 'Skipping refresh while stopping');
22
- return;
23
- }
24
-
25
- if (this.isRefreshing) {
26
- logger.agent(this.name, 'Already refreshing, skipping...');
27
- return;
28
- }
29
-
30
- logger.agent(this.name, `Starting refresh${this.mode === 'direct' ? ' (direct quota mode)' : ' (direct quota mode with PTY fallback)'}...`);
31
- this.isRefreshing = true;
32
- this.error = null;
33
- this.auth = null;
34
-
35
- try {
36
- const result = await this.directClient.fetchQuotaUsage();
37
- this.usage = result.usage;
38
- this.metadata = result.metadata || this.metadata;
39
- this._metadataFetched = !!this.metadata;
40
- this.lastUpdated = new Date().toISOString();
41
- logger.agent(this.name, 'Parsed direct quota usage:', logger.json(this.usage));
42
- } catch (err) {
43
- if (this.mode === 'fallback') {
44
- logger.agent(this.name, `Direct quota fetch failed, falling back to PTY: ${err.message}`);
45
- this.isRefreshing = false;
46
- return super.refresh();
47
- }
48
- logger.error(`[${this.name}] Error during direct quota refresh:`, err.message);
49
- this.error = err.message;
50
- } finally {
51
- if (this.isRefreshing) {
52
- this.isRefreshing = false;
53
- logger.agent(this.name, 'Refresh complete');
54
- }
55
- }
56
- }
57
-
58
- getTimeout() {
59
- return 35000; // 35 seconds - Gemini can take 10+ seconds to start
60
- }
61
-
62
- getEnv() {
63
- // Use xterm-256color for proper terminal support
64
- // Gemini CLI needs good terminal emulation for its TUI
65
- return {
66
- ...process.env,
67
- TERM: 'xterm-256color',
68
- COLORTERM: 'truecolor',
69
- };
70
- }
71
-
72
- _hasReadyPrompt(output) {
73
- const clean = this.stripAnsi(output);
74
- return clean.includes('Type your message')
75
- || clean.includes('gemini>')
76
- || /Ready\s*\(/.test(clean);
77
- }
78
-
79
- isReadyForCommands(output) {
80
- // Don't consider ready if authentication is in progress
81
- if (this._isAuthenticating(output)) {
82
- return false;
83
- }
84
- return this._hasReadyPrompt(output);
85
- }
86
-
87
- _isAuthenticating(output) {
88
- // Detect OAuth/authentication screens where Escape would cancel the flow
89
- const clean = this.stripAnsi(output);
90
- const hasAuthFlow = /waiting for authentication/i.test(clean)
91
- || /press esc.*to cancel/i.test(clean)
92
- || /authenticating\.\.\./i.test(clean);
93
-
94
- if (!hasAuthFlow) {
95
- return false;
96
- }
97
-
98
- // Newer Gemini builds can render footer hints like "? for shortcuts"
99
- // before the actual input prompt is available. Treat auth as complete
100
- // only when the real prompt has returned.
101
- return !this._hasReadyPrompt(clean);
102
- }
103
-
104
- detectAuthenticationState(output) {
105
- const clean = this.stripAnsi(output);
106
- if (!clean) return null;
107
-
108
- const hasAuthMenu = /how would you like to authenticate for this project\?/i.test(clean)
109
- || /get started/i.test(clean);
110
- const hasAuthFailure = /failed to sign in\.\s*message:\s*([^\n]+)/i.test(clean)
111
- || /please run gemini cli in an interactive terminal to authenticate/i.test(clean)
112
- || /authentication consent could not be obtained/i.test(clean);
113
-
114
- if (!hasAuthMenu && !hasAuthFailure) {
115
- return null;
116
- }
117
-
118
- const failureMatch = clean.match(/failed to sign in\.\s*message:\s*([^\n]+)/i);
119
- const detail = failureMatch
120
- ? `Failed to sign in: ${failureMatch[1].trim()}`
121
- : 'Gemini CLI is waiting for authentication.';
122
- const action = /NO_BROWSER=true/i.test(clean)
123
- ? 'Run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.'
124
- : 'Run Gemini CLI in an interactive terminal to authenticate.';
125
-
126
- return {
127
- authenticated: false,
128
- status: 'unauthenticated',
129
- message: 'Authentication required',
130
- detail,
131
- action,
132
- };
133
- }
134
-
135
- handleTrustPrompt(shell, output) {
136
- // Check for trust prompt with various wordings
137
- const trustPatterns = [
138
- 'Do you trust',
139
- 'trust the files',
140
- 'trust this folder',
141
- 'Trust this workspace',
142
- 'allow access',
143
- 'grant access',
144
- 'grant full access',
145
- ];
146
-
147
- if (trustPatterns.some(pattern => output.toLowerCase().includes(pattern.toLowerCase()))) {
148
- logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
149
- // v0.35+: numbered menu with "1. Trust folder" pre-selected — Enter accepts it
150
- shell.write('1\r');
151
-
152
- // v0.35+ does an internal restart after trust — send Escape after a delay
153
- // to dismiss any transitional UI before the process becomes ready again
154
- setTimeout(() => {
155
- if (!shell) return;
156
- logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
157
- shell.write('\x1b');
158
- }, 3000);
159
-
160
- return true;
161
- }
162
- return false;
163
- }
164
-
165
- hasCompleteOutput(output) {
166
- // Gemini shows "Usage limits" after the model usage table (v0.24.x)
167
- if (output.includes('Usage limits span all sessions')) return true;
168
- const clean = this.stripAnsi(output);
169
- if (this.detectAuthenticationState(clean)) return true;
170
- const hasModel = /gemini-[\w.-]+/i.test(clean);
171
- const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
172
- // v0.24: "Resets in 3h 26m", v0.35+: "(23h 21m)" after time
173
- const hasResets = /resets?\s+in/i.test(clean) || /\(\d+[hmd]\s+\d+[hmd]?\)/i.test(clean);
174
- return hasModel && hasPercent && hasResets;
175
- }
176
-
177
- // Convert duration string like "3h 26m" or "5d 2h" to seconds
178
- parseDurationToSeconds(durationStr) {
179
- if (!durationStr) return null;
180
-
181
- let totalSeconds = 0;
182
-
183
- // Match days
184
- const daysMatch = durationStr.match(/(\d+)\s*d/i);
185
- if (daysMatch) {
186
- totalSeconds += parseInt(daysMatch[1]) * 24 * 60 * 60;
187
- }
188
-
189
- // Match hours
190
- const hoursMatch = durationStr.match(/(\d+)\s*h/i);
191
- if (hoursMatch) {
192
- totalSeconds += parseInt(hoursMatch[1]) * 60 * 60;
193
- }
194
-
195
- // Match minutes
196
- const minsMatch = durationStr.match(/(\d+)\s*m/i);
197
- if (minsMatch) {
198
- totalSeconds += parseInt(minsMatch[1]) * 60;
199
- }
200
-
201
- return totalSeconds > 0 ? totalSeconds : null;
202
- }
203
-
204
- sendCommands(shell, output) {
205
- // Check if authentication is still in progress - don't send commands that could interfere
206
- if (this._isAuthenticating(output) || this.detectAuthenticationState(output)) {
207
- logger.agent(this.name, 'Authentication required or in progress, cannot send /stats command');
208
- // Don't retry - let the timeout handle it. Sending Escape would cancel auth.
209
- return;
210
- }
211
- logger.agent(this.name, 'Sending /stats command...');
212
- // Newer Gemini builds use Esc for "rewind", which can derail the command flow.
213
- // Submit /stats directly once the prompt is ready.
214
- setTimeout(() => shell.write('/stats'), 200);
215
- setTimeout(() => shell.write('\r'), 600);
216
- }
217
-
218
- // Calculate pace data for a model entry (Gemini uses 24h sessions)
219
- _addPaceData(entry, resetsInSeconds) {
220
- const cycleDuration = CYCLE_DURATIONS.sessionGemini;
221
- if (!cycleDuration || resetsInSeconds == null) return;
222
- const paceData = calculatePace({ usagePercent: entry.percentUsed, resetsInSeconds, cycleDurationSeconds: cycleDuration });
223
- if (paceData) entry.pace = paceData;
224
- }
225
-
226
- parseOutput(output) {
227
- const clean = this.stripAnsi(output);
228
- const usage = {
229
- models: [],
230
- };
231
-
232
- // Parse model usage lines
233
- // Format: "gemini-2.5-flash -90.2% (Resets in 3h 26m)" or "gemini-2.5-flash 90.2% (Resets in 3h 26m)"
234
- const lines = clean.split('\n');
235
-
236
- for (const line of lines) {
237
- // v0.24: "gemini-2.5-flash 90.2% (Resets in 3h 26m)" — percentage is REMAINING
238
- const matchOld = line.match(/(gemini-[\w.-]+)\s+[-\s]*(\d+(?:\.\d+)?)\s*%\s*\(Resets in\s*([^)]+)\)/i);
239
- // v0.35+: "gemini-2.5-flash - ▬▬▬ 6% 10:46 PM (23h 21m)" — percentage is USED
240
- const matchNew = !matchOld && line.match(/(gemini-[\w.-]+)\s+.*?(\d+(?:\.\d+)?)\s*%.*?\((\d+[hmd]\s*\d*[hmd]?)\)/i);
241
- const match = matchOld || matchNew;
242
- if (match) {
243
- const model = match[1];
244
- const rawPercent = parseFloat(match[2]);
245
- const resetsIn = match[3].trim();
246
- const percentUsed = matchOld ? parseFloat((100 - rawPercent).toFixed(1)) : rawPercent;
247
- const resetsInSeconds = this.parseDurationToSeconds(resetsIn);
248
-
249
- // Avoid duplicates
250
- if (!usage.models.find(m => m.model === model)) {
251
- const usageLeft = matchOld ? rawPercent : parseFloat((100 - rawPercent).toFixed(1));
252
- const modelEntry = { model, usageLeft, resetsIn, percentUsed, resetsInSeconds };
253
- this._addPaceData(modelEntry, resetsInSeconds);
254
- usage.models.push(modelEntry);
255
- }
256
- }
257
- }
258
-
259
- // Detect version update notification in output
260
- // Gemini CLI shows: "Update available! 0.24.5 → 0.32.1" or similar
261
- const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
262
- if (updateMatch) {
263
- usage.version = { current: updateMatch[1], latest: updateMatch[2] };
264
- }
265
-
266
- return usage;
267
- }
268
-
269
- // Fetch metadata by sending /about command once on first refresh
270
- async fetchMetadata() {
271
- if (this._aboutSent) {
272
- return this.metadata;
273
- }
274
-
275
- // Ensure process is spawned and ready
276
- if (!this.shell || !this.processReady) {
277
- await this.spawnProcess();
278
- }
279
-
280
- if (this.detectAuthenticationState(this.output)) {
281
- return null;
282
- }
283
-
284
- return new Promise((resolve, _reject) => {
285
- let aboutOutput = '';
286
- let completed = false;
287
-
288
- const finish = (result) => {
289
- if (completed) return;
290
- completed = true;
291
- this._aboutSent = true;
292
- this._onDataCallback = null;
293
- this._commandInFlight = false;
294
- clearTimeout(timer);
295
- resolve(result);
296
- };
297
-
298
- const timer = setTimeout(() => {
299
- logger.agent(this.name, '/about timeout, using partial output');
300
- finish(this._parseAboutOutput(aboutOutput));
301
- }, 10000); // 10 second timeout for /about
302
-
303
- this._commandInFlight = true;
304
- let trustHandled = false;
305
- this._onDataCallback = () => {
306
- aboutOutput = this.output;
307
- // Handle trust prompts that appear during command execution
308
- if (!trustHandled && this.handleTrustPrompt(this.shell, aboutOutput)) {
309
- trustHandled = true;
310
- aboutOutput = '';
311
- this.output = '';
312
- return;
313
- }
314
- // Check if we have complete /about output
315
- if (this._hasCompleteAboutOutput(aboutOutput)) {
316
- logger.agent(this.name, 'Complete /about output detected');
317
- setTimeout(() => finish(this._parseAboutOutput(aboutOutput)), 100);
318
- }
319
- };
320
-
321
- logger.agent(this.name, 'Sending /about command for metadata...');
322
- this.output = '';
323
-
324
- // Wait for authentication to complete before sending commands
325
- // Auth can start after spawn if token refresh is needed
326
- // Gemini can take 10+ seconds to fully start up
327
- let authWaitCount = 0;
328
- const maxAuthWait = 15; // Max 15 seconds waiting for auth
329
-
330
- const waitForAuth = () => {
331
- if (this.detectAuthenticationState(this.output)) {
332
- logger.agent(this.name, 'Authentication required, skipping /about metadata fetch');
333
- finish(null);
334
- return;
335
- }
336
- if (this._isAuthenticating(this.output)) {
337
- authWaitCount++;
338
- if (authWaitCount >= maxAuthWait) {
339
- logger.agent(this.name, 'Authentication timeout - Gemini may be stuck in auth flow');
340
- // Don't send commands that would interfere with stuck auth
341
- // Let the overall timeout handle this
342
- return;
343
- }
344
- logger.agent(this.name, 'Authentication in progress, waiting...');
345
- setTimeout(waitForAuth, 1000);
346
- return;
347
- }
348
- // Auth complete (or not happening), send commands
349
- // Guard against shell being nullified if process exits during setTimeout delays
350
- // Don't send Escape first - it can cancel auth flows that started between checks
351
- setTimeout(() => this.shell?.write('/about\r'), 500);
352
- setTimeout(() => this.shell?.write('\r'), 1000);
353
- };
354
-
355
- setTimeout(waitForAuth, 100);
356
- });
357
- }
358
-
359
- _hasCompleteAboutOutput(output) {
360
- const clean = this.stripAnsi(output);
361
- // Require actual /about content (not just status bar text)
362
- const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
363
- // Require prompt to have returned after the about box
364
- const hasPrompt = clean.includes('Type your message');
365
- return hasAboutContent && hasPrompt;
366
- }
367
-
368
- _parseAboutOutput(output) {
369
- const clean = this.stripAnsi(output);
370
-
371
- // Guard: verify we have actual /about output, not just status bar noise.
372
- // The status bar contains "no sandbox (see /docs) Auto (Gemini 2.5) /model"
373
- // which can false-positive match loose regexes.
374
- const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
375
- if (!hasAboutContent) {
376
- logger.agent(this.name, '/about output missing expected header, dumping for debug');
377
- require('fs').writeFileSync(`/tmp/${this.name}-about-output.txt`, output);
378
- logger.agent(this.name, 'Stripped output preview:', logger.dim(clean.substring(0, 500)));
379
- return null;
380
- }
381
-
382
- const metadata = {};
383
-
384
- // Gemini /about uses whitespace-separated key-value pairs (no colons):
385
- // CLI Version 0.24.5
386
- // OS linux
387
- // Auth Method OAuth
388
- // User Email livecart@gmail.com
389
- // After stripAnsi collapses multi-spaces, these become single-space separated.
390
-
391
- // Extract version: "CLI Version 0.24.5"
392
- const versionMatch = clean.match(/CLI\s+Version[:\s]+v?([\d.]+)/i);
393
- if (versionMatch) {
394
- metadata.version = this.stripBoxChars(versionMatch[1]);
395
- }
396
-
397
- // Extract email: "User Email livecart@gmail.com"
398
- const emailMatch = clean.match(/User\s+Email[:\s]+(\S+@\S+)/i);
399
- if (emailMatch) {
400
- metadata.email = this.stripBoxChars(emailMatch[1]);
401
- }
402
-
403
- // Extract auth method: "Auth Method OAuth"
404
- const authMatch = clean.match(/Auth\s*Method[:\s]+(\w+)/i);
405
- if (authMatch) {
406
- metadata.authMethod = this.stripBoxChars(authMatch[1]);
407
- }
408
-
409
- // Extract model: "Model auto-gemini-2.5" (but NOT "/model" from the status bar)
410
- const modelMatch = clean.match(/(?<!\/)Model[:\s]+([\w.-]+)/i);
411
- if (modelMatch) {
412
- metadata.model = this.stripBoxChars(modelMatch[1]);
413
- }
414
-
415
- // Extract OS: "OS linux"
416
- const osMatch = clean.match(/\bOS[:\s]+(\w+)/i);
417
- if (osMatch) {
418
- metadata.os = this.stripBoxChars(osMatch[1]);
419
- }
420
-
421
- // Detect version update notification
422
- const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
423
- if (updateMatch) {
424
- metadata.updateAvailable = { current: updateMatch[1], latest: updateMatch[2] };
425
- }
426
-
427
- return Object.keys(metadata).length > 0 ? metadata : null;
428
- }
429
-
430
- /**
431
- * Lightweight keepalive to prevent session expiration.
432
- * Sends escape key to maintain the PTY session without triggering API calls.
433
- *
434
- * @returns {Promise<boolean>} True if keepalive succeeded
435
- */
436
- async keepalive() {
437
- if (this.mode === 'direct' || (this.mode === 'fallback' && (!this.shell || !this.processReady))) {
438
- console.log(`[${this.name}] Keepalive skipped (${this.mode} mode)`);
439
- return true;
440
- }
441
-
442
- if (this.freshProcess) {
443
- console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
444
- return true;
445
- }
446
-
447
- if (!this.shell || !this.processReady) {
448
- console.log(`[${this.name}] Keepalive: spawning process...`);
449
- await this.spawnProcess();
450
- }
451
-
452
- if (this.shell) {
453
- console.log(`[${this.name}] Keepalive: sending ping...`);
454
- // Send escape key to dismiss any UI and trigger activity
455
- this.shell.write('\x1b');
456
- return true;
457
- }
458
-
459
- return false;
460
- }
461
- }
462
-
463
- module.exports = { GeminiAgent };