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.
- package/README.md +16 -31
- package/bin/agent-tank.js +4 -12
- package/package.json +5 -3
- package/src/activity-monitor.js +18 -7
- package/src/agents/agy.js +301 -0
- package/src/agents/base.js +1 -1
- package/src/agents/claude.js +39 -10
- package/src/agents/pty-output-parser.js +10 -4
- package/src/client-auto-refresh-helpers.js +7 -7
- package/src/client-auto-refresh.js +2 -2
- package/src/client-monitor.js +2 -2
- package/src/discovery.js +9 -24
- package/src/history-store.js +1 -1
- package/src/icons.js +2 -2
- package/src/index.js +5 -6
- package/src/logger.js +2 -2
- package/src/pace-attachment.js +7 -7
- package/src/public-status.js +5 -5
- package/src/snapshot-metrics.js +6 -6
- package/src/status-page.css +6 -6
- package/src/status-page.js +10 -2
- package/src/usage-formatters.js +7 -8
- package/src/agents/gemini-direct.js +0 -234
- package/src/agents/gemini.js +0 -463
|
@@ -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
|
-
};
|
package/src/agents/gemini.js
DELETED
|
@@ -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 };
|