agent-tank 0.9.0

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.
@@ -0,0 +1,302 @@
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 25000; // 25 seconds
14
+ }
15
+
16
+ isReadyForCommands(output) {
17
+ // v0.24: "Type your message" prompt
18
+ // v0.35+: "Ready" in terminal title + visible prompt area (no "Type your message")
19
+ // Also check for terminal title sequence containing "Ready"
20
+ return output.includes('Type your message')
21
+ || output.includes('gemini>')
22
+ || /Ready\s*\(/.test(output);
23
+ }
24
+
25
+ handleTrustPrompt(shell, output) {
26
+ // Check for trust prompt with various wordings
27
+ const trustPatterns = [
28
+ 'Do you trust',
29
+ 'trust the files',
30
+ 'trust this folder',
31
+ 'Trust this workspace',
32
+ 'allow access',
33
+ 'grant access',
34
+ 'grant full access',
35
+ ];
36
+
37
+ if (trustPatterns.some(pattern => output.toLowerCase().includes(pattern.toLowerCase()))) {
38
+ logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
39
+ // v0.35+: numbered menu with "1. Trust folder" pre-selected — Enter accepts it
40
+ shell.write('1\r');
41
+
42
+ // v0.35+ does an internal restart after trust — send Escape after a delay
43
+ // to dismiss any transitional UI before the process becomes ready again
44
+ setTimeout(() => {
45
+ logger.agent(this.name, 'Post-trust: sending Escape to clear restart UI...');
46
+ shell.write('\x1b');
47
+ }, 3000);
48
+
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+
54
+ hasCompleteOutput(output) {
55
+ // Gemini shows "Usage limits" after the model usage table (v0.24.x)
56
+ if (output.includes('Usage limits span all sessions')) return true;
57
+ const clean = this.stripAnsi(output);
58
+ const hasModel = /gemini-[\w.-]+/i.test(clean);
59
+ const hasPercent = /\d+(?:\.\d+)?%/i.test(clean);
60
+ // v0.24: "Resets in 3h 26m", v0.35+: "(23h 21m)" after time
61
+ const hasResets = /resets?\s+in/i.test(clean) || /\(\d+[hmd]\s+\d+[hmd]?\)/i.test(clean);
62
+ return hasModel && hasPercent && hasResets;
63
+ }
64
+
65
+ // Convert duration string like "3h 26m" or "5d 2h" to seconds
66
+ parseDurationToSeconds(durationStr) {
67
+ if (!durationStr) return null;
68
+
69
+ let totalSeconds = 0;
70
+
71
+ // Match days
72
+ const daysMatch = durationStr.match(/(\d+)\s*d/i);
73
+ if (daysMatch) {
74
+ totalSeconds += parseInt(daysMatch[1]) * 24 * 60 * 60;
75
+ }
76
+
77
+ // Match hours
78
+ const hoursMatch = durationStr.match(/(\d+)\s*h/i);
79
+ if (hoursMatch) {
80
+ totalSeconds += parseInt(hoursMatch[1]) * 60 * 60;
81
+ }
82
+
83
+ // Match minutes
84
+ const minsMatch = durationStr.match(/(\d+)\s*m/i);
85
+ if (minsMatch) {
86
+ totalSeconds += parseInt(minsMatch[1]) * 60;
87
+ }
88
+
89
+ return totalSeconds > 0 ? totalSeconds : null;
90
+ }
91
+
92
+ sendCommands(shell, _output) {
93
+ logger.agent(this.name, 'Sending /stats command...');
94
+ // v0.35+: typing /stats triggers autocomplete dropdown.
95
+ // Escape dismisses autocomplete, then Enter submits the command.
96
+ setTimeout(() => shell.write('\x1b'), 50);
97
+ setTimeout(() => shell.write('/stats'), 500);
98
+ setTimeout(() => shell.write('\x1b'), 1000);
99
+ setTimeout(() => shell.write('\r'), 1500);
100
+ }
101
+
102
+ // Calculate pace data for a model entry (Gemini uses 24h sessions)
103
+ _addPaceData(entry, resetsInSeconds) {
104
+ const cycleDuration = CYCLE_DURATIONS.sessionGemini;
105
+ if (!cycleDuration || resetsInSeconds == null) return;
106
+ const paceData = calculatePace({ usagePercent: entry.percentUsed, resetsInSeconds, cycleDurationSeconds: cycleDuration });
107
+ if (paceData) entry.pace = paceData;
108
+ }
109
+
110
+ parseOutput(output) {
111
+ const clean = this.stripAnsi(output);
112
+ const usage = {
113
+ models: [],
114
+ };
115
+
116
+ // Parse model usage lines
117
+ // Format: "gemini-2.5-flash -90.2% (Resets in 3h 26m)" or "gemini-2.5-flash 90.2% (Resets in 3h 26m)"
118
+ const lines = clean.split('\n');
119
+
120
+ for (const line of lines) {
121
+ // v0.24: "gemini-2.5-flash 90.2% (Resets in 3h 26m)" — percentage is REMAINING
122
+ const matchOld = line.match(/(gemini-[\w.-]+)\s+[-\s]*(\d+(?:\.\d+)?)\s*%\s*\(Resets in\s*([^)]+)\)/i);
123
+ // v0.35+: "gemini-2.5-flash - ▬▬▬ 6% 10:46 PM (23h 21m)" — percentage is USED
124
+ const matchNew = !matchOld && line.match(/(gemini-[\w.-]+)\s+.*?(\d+(?:\.\d+)?)\s*%.*?\((\d+[hmd]\s*\d*[hmd]?)\)/i);
125
+ const match = matchOld || matchNew;
126
+ if (match) {
127
+ const model = match[1];
128
+ const rawPercent = parseFloat(match[2]);
129
+ const resetsIn = match[3].trim();
130
+ const percentUsed = matchOld ? parseFloat((100 - rawPercent).toFixed(1)) : rawPercent;
131
+ const resetsInSeconds = this.parseDurationToSeconds(resetsIn);
132
+
133
+ // Avoid duplicates
134
+ if (!usage.models.find(m => m.model === model)) {
135
+ const usageLeft = matchOld ? rawPercent : parseFloat((100 - rawPercent).toFixed(1));
136
+ const modelEntry = { model, usageLeft, resetsIn, percentUsed, resetsInSeconds };
137
+ this._addPaceData(modelEntry, resetsInSeconds);
138
+ usage.models.push(modelEntry);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Detect version update notification in output
144
+ // Gemini CLI shows: "Update available! 0.24.5 → 0.32.1" or similar
145
+ const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
146
+ if (updateMatch) {
147
+ usage.version = { current: updateMatch[1], latest: updateMatch[2] };
148
+ }
149
+
150
+ return usage;
151
+ }
152
+
153
+ // Fetch metadata by sending /about command once on first refresh
154
+ async fetchMetadata() {
155
+ if (this._aboutSent) {
156
+ return this.metadata;
157
+ }
158
+
159
+ // Ensure process is spawned and ready
160
+ if (!this.shell || !this.processReady) {
161
+ await this.spawnProcess();
162
+ }
163
+
164
+ return new Promise((resolve, _reject) => {
165
+ let aboutOutput = '';
166
+ let completed = false;
167
+
168
+ const finish = (result) => {
169
+ if (completed) return;
170
+ completed = true;
171
+ this._aboutSent = true;
172
+ this._onDataCallback = null;
173
+ this._commandInFlight = false;
174
+ clearTimeout(timer);
175
+ resolve(result);
176
+ };
177
+
178
+ const timer = setTimeout(() => {
179
+ logger.agent(this.name, '/about timeout, using partial output');
180
+ finish(this._parseAboutOutput(aboutOutput));
181
+ }, 10000); // 10 second timeout for /about
182
+
183
+ this._commandInFlight = true;
184
+ this._onDataCallback = () => {
185
+ aboutOutput = this.output;
186
+ // Check if we have complete /about output
187
+ if (this._hasCompleteAboutOutput(aboutOutput)) {
188
+ logger.agent(this.name, 'Complete /about output detected');
189
+ setTimeout(() => finish(this._parseAboutOutput(aboutOutput)), 100);
190
+ }
191
+ };
192
+
193
+ logger.agent(this.name, 'Sending /about command for metadata...');
194
+ 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);
200
+ });
201
+ }
202
+
203
+ _hasCompleteAboutOutput(output) {
204
+ const clean = this.stripAnsi(output);
205
+ // Require actual /about content (not just status bar text)
206
+ const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
207
+ // Require prompt to have returned after the about box
208
+ const hasPrompt = clean.includes('Type your message');
209
+ return hasAboutContent && hasPrompt;
210
+ }
211
+
212
+ _parseAboutOutput(output) {
213
+ const clean = this.stripAnsi(output);
214
+
215
+ // Guard: verify we have actual /about output, not just status bar noise.
216
+ // The status bar contains "no sandbox (see /docs) Auto (Gemini 2.5) /model"
217
+ // which can false-positive match loose regexes.
218
+ const hasAboutContent = /about\s+gemini/i.test(clean) || /CLI\s+Version/i.test(clean);
219
+ if (!hasAboutContent) {
220
+ logger.agent(this.name, '/about output missing expected header, dumping for debug');
221
+ require('fs').writeFileSync(`/tmp/${this.name}-about-output.txt`, output);
222
+ logger.agent(this.name, 'Stripped output preview:', logger.dim(clean.substring(0, 500)));
223
+ return null;
224
+ }
225
+
226
+ const metadata = {};
227
+
228
+ // Gemini /about uses whitespace-separated key-value pairs (no colons):
229
+ // CLI Version 0.24.5
230
+ // OS linux
231
+ // Auth Method OAuth
232
+ // User Email livecart@gmail.com
233
+ // After stripAnsi collapses multi-spaces, these become single-space separated.
234
+
235
+ // Extract version: "CLI Version 0.24.5"
236
+ const versionMatch = clean.match(/CLI\s+Version[:\s]+v?([\d.]+)/i);
237
+ if (versionMatch) {
238
+ metadata.version = this.stripBoxChars(versionMatch[1]);
239
+ }
240
+
241
+ // Extract email: "User Email livecart@gmail.com"
242
+ const emailMatch = clean.match(/User\s+Email[:\s]+(\S+@\S+)/i);
243
+ if (emailMatch) {
244
+ metadata.email = this.stripBoxChars(emailMatch[1]);
245
+ }
246
+
247
+ // Extract auth method: "Auth Method OAuth"
248
+ const authMatch = clean.match(/Auth\s*Method[:\s]+(\w+)/i);
249
+ if (authMatch) {
250
+ metadata.authMethod = this.stripBoxChars(authMatch[1]);
251
+ }
252
+
253
+ // Extract model: "Model auto-gemini-2.5" (but NOT "/model" from the status bar)
254
+ const modelMatch = clean.match(/(?<!\/)Model[:\s]+([\w.-]+)/i);
255
+ if (modelMatch) {
256
+ metadata.model = this.stripBoxChars(modelMatch[1]);
257
+ }
258
+
259
+ // Extract OS: "OS linux"
260
+ const osMatch = clean.match(/\bOS[:\s]+(\w+)/i);
261
+ if (osMatch) {
262
+ metadata.os = this.stripBoxChars(osMatch[1]);
263
+ }
264
+
265
+ // Detect version update notification
266
+ const updateMatch = clean.match(/[Uu]pdate\s+available[^\d]*([\d.]+)\s*(?:→|->|to)\s*([\d.]+)/);
267
+ if (updateMatch) {
268
+ metadata.updateAvailable = { current: updateMatch[1], latest: updateMatch[2] };
269
+ }
270
+
271
+ return Object.keys(metadata).length > 0 ? metadata : null;
272
+ }
273
+
274
+ /**
275
+ * Lightweight keepalive to prevent session expiration.
276
+ * Sends escape key to maintain the PTY session without triggering API calls.
277
+ *
278
+ * @returns {Promise<boolean>} True if keepalive succeeded
279
+ */
280
+ async keepalive() {
281
+ if (this.freshProcess) {
282
+ console.log(`[${this.name}] Keepalive skipped (fresh process mode)`);
283
+ return true;
284
+ }
285
+
286
+ if (!this.shell || !this.processReady) {
287
+ console.log(`[${this.name}] Keepalive: spawning process...`);
288
+ await this.spawnProcess();
289
+ }
290
+
291
+ if (this.shell) {
292
+ console.log(`[${this.name}] Keepalive: sending ping...`);
293
+ // Send escape key to dismiss any UI and trigger activity
294
+ this.shell.write('\x1b');
295
+ return true;
296
+ }
297
+
298
+ return false;
299
+ }
300
+ }
301
+
302
+ module.exports = { GeminiAgent };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Shared helper for pingKeepalive functionality.
3
+ * Extracts common PTY-based keepalive logic to reduce code duplication.
4
+ */
5
+
6
+ const pty = require('node-pty');
7
+
8
+ /**
9
+ * Spawns a fresh CLI process, sends a command to refresh the session token,
10
+ * then tears down the process cleanly. This is a silent operation that
11
+ * does not affect the main usage/error state.
12
+ *
13
+ * @param {object} config - Configuration for the keepalive ping
14
+ * @param {string} config.name - Agent name for logging
15
+ * @param {string} config.command - CLI command to spawn
16
+ * @param {string[]} config.args - Arguments for the CLI command
17
+ * @param {object} config.env - Environment variables for the process
18
+ * @param {string} config.termName - Terminal name (e.g., 'xterm-color', 'xterm-256color')
19
+ * @param {function} config.isReady - Function to check if CLI is ready for commands
20
+ * @param {function} config.sendCommand - Function to send the keepalive command
21
+ * @param {function} config.isComplete - Function to check if response is complete
22
+ * @param {function} [config.handlePrompts] - Optional function to handle interactive prompts
23
+ * @param {function} [config.respondToTerminalQueries] - Optional function to respond to terminal queries
24
+ * @param {number} [config.timeout=10000] - Timeout in milliseconds
25
+ * @returns {Promise<boolean>} True if the ping succeeded
26
+ */
27
+ async function pingKeepalive(config) {
28
+ const {
29
+ name,
30
+ command,
31
+ args,
32
+ env,
33
+ termName,
34
+ isReady,
35
+ sendCommand,
36
+ isComplete,
37
+ handlePrompts,
38
+ respondToTerminalQueries,
39
+ timeout = 10000,
40
+ } = config;
41
+
42
+ console.log(`[${name}] pingKeepalive: spawning fresh process for session refresh...`);
43
+
44
+ return new Promise((resolve) => {
45
+ let shell = null;
46
+ let output = '';
47
+ let commandsSent = false;
48
+ let completed = false;
49
+
50
+ const cleanup = (success) => {
51
+ if (completed) return;
52
+ completed = true;
53
+ clearTimeout(timer);
54
+ if (shell) {
55
+ try { shell.kill(); } catch (_e) { /* Process may already be dead */ }
56
+ }
57
+ console.log(`[${name}] pingKeepalive: ${success ? 'succeeded' : 'failed'}`);
58
+ resolve(success);
59
+ };
60
+
61
+ const timer = setTimeout(() => {
62
+ console.log(`[${name}] pingKeepalive: timeout after ${timeout / 1000}s`);
63
+ cleanup(false);
64
+ }, timeout);
65
+
66
+ try {
67
+ shell = pty.spawn(command, args, {
68
+ name: termName,
69
+ cols: 120,
70
+ rows: 40,
71
+ cwd: '/tmp',
72
+ env,
73
+ });
74
+ } catch (spawnErr) {
75
+ console.error(`[${name}] pingKeepalive: spawn failed: ${spawnErr.message}`);
76
+ cleanup(false);
77
+ return;
78
+ }
79
+
80
+ shell.onData((data) => {
81
+ output += data;
82
+
83
+ // Handle interactive prompts if handler provided
84
+ if (handlePrompts) {
85
+ handlePrompts(shell, data, output);
86
+ }
87
+
88
+ // Respond to terminal capability queries if handler provided
89
+ if (respondToTerminalQueries) {
90
+ respondToTerminalQueries(data, shell);
91
+ }
92
+
93
+ // Wait for CLI to be ready, then send command
94
+ if (!commandsSent && isReady(output)) {
95
+ console.log(`[${name}] pingKeepalive: CLI ready, sending command...`);
96
+ commandsSent = true;
97
+ sendCommand(shell);
98
+ }
99
+
100
+ // Check if response is complete
101
+ if (commandsSent && isComplete(output)) {
102
+ console.log(`[${name}] pingKeepalive: response received`);
103
+ setTimeout(() => cleanup(true), 100);
104
+ }
105
+ });
106
+
107
+ shell.onExit(({ exitCode }) => {
108
+ console.log(`[${name}] pingKeepalive: process exited with code ${exitCode}`);
109
+ // Consider success if we sent commands (session was refreshed)
110
+ cleanup(commandsSent);
111
+ });
112
+ });
113
+ }
114
+
115
+ module.exports = { pingKeepalive };
@@ -0,0 +1,210 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const https = require('https');
5
+
6
+ const OAUTH_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
7
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
8
+ const OAUTH_DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
9
+ // Refresh 5 minutes before actual expiry to avoid race conditions
10
+ const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
11
+
12
+ /**
13
+ * Read OAuth credentials from the credentials file.
14
+ * @param {string} credentialsPath
15
+ * @returns {{ accessToken: string, refreshToken?: string, expiresAt?: number }|null}
16
+ */
17
+ function readCredentialsFile(credentialsPath) {
18
+ try {
19
+ if (fs.existsSync(credentialsPath)) {
20
+ const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf8'));
21
+ const oauth = credentials.claudeAiOauth;
22
+ if (oauth?.accessToken) {
23
+ return {
24
+ accessToken: oauth.accessToken,
25
+ refreshToken: oauth.refreshToken || null,
26
+ expiresAt: oauth.expiresAt || null,
27
+ };
28
+ }
29
+ }
30
+ } catch (_err) {
31
+ // Fall through
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Read OAuth credentials from macOS Keychain.
38
+ * @returns {{ accessToken: string, refreshToken?: string, expiresAt?: number }|null}
39
+ */
40
+ function readKeychainCredentials() {
41
+ if (process.platform !== 'darwin') return null;
42
+ try {
43
+ const result = execSync(
44
+ 'security find-generic-password -s "claude-code" -w 2>/dev/null',
45
+ { encoding: 'utf8', timeout: 5000 }
46
+ ).trim();
47
+ if (result) {
48
+ const oauth = JSON.parse(result).claudeAiOauth;
49
+ if (oauth?.accessToken) {
50
+ return {
51
+ accessToken: oauth.accessToken,
52
+ refreshToken: oauth.refreshToken || null,
53
+ expiresAt: oauth.expiresAt || null,
54
+ };
55
+ }
56
+ }
57
+ } catch (_err) {
58
+ // Keychain entry not found or error parsing
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Read OAuth credentials from Linux secret-tool.
65
+ * @returns {{ accessToken: string, refreshToken?: string, expiresAt?: number }|null}
66
+ */
67
+ function readSecretToolCredentials() {
68
+ if (process.platform !== 'linux') return null;
69
+ try {
70
+ const result = execSync(
71
+ 'secret-tool lookup service claude-code 2>/dev/null',
72
+ { encoding: 'utf8', timeout: 5000 }
73
+ ).trim();
74
+ if (result) {
75
+ const oauth = JSON.parse(result).claudeAiOauth;
76
+ if (oauth?.accessToken) {
77
+ return {
78
+ accessToken: oauth.accessToken,
79
+ refreshToken: oauth.refreshToken || null,
80
+ expiresAt: oauth.expiresAt || null,
81
+ };
82
+ }
83
+ }
84
+ } catch (_err) {
85
+ // secret-tool not available or entry not found
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Read OAuth credentials from all available sources in priority order.
92
+ * @returns {{ accessToken: string, refreshToken?: string, expiresAt?: number, source: string }|null}
93
+ */
94
+ function readCredentials() {
95
+ // 1. Environment variable (no refresh possible)
96
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
97
+ return { accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: 'env' };
98
+ }
99
+
100
+ // 2. Credentials file
101
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
102
+ const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
103
+ const fileCreds = readCredentialsFile(credentialsPath);
104
+ if (fileCreds) return { ...fileCreds, source: 'credentials_file' };
105
+
106
+ // 3. macOS Keychain
107
+ const keychainCreds = readKeychainCredentials();
108
+ if (keychainCreds) return { ...keychainCreds, source: 'keychain' };
109
+
110
+ // 4. Linux secret-tool
111
+ const secretCreds = readSecretToolCredentials();
112
+ if (secretCreds) return { ...secretCreds, source: 'secret_tool' };
113
+
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * Check if an access token is expired (or about to expire).
119
+ * @param {number|null} expiresAt - Expiry timestamp in milliseconds
120
+ * @returns {boolean}
121
+ */
122
+ function isTokenExpired(expiresAt) {
123
+ if (!expiresAt) return false; // No expiry info, assume valid
124
+ return Date.now() >= expiresAt - TOKEN_EXPIRY_BUFFER_MS;
125
+ }
126
+
127
+ /**
128
+ * Refresh the OAuth access token using the refresh token.
129
+ * @param {string} refreshToken
130
+ * @returns {Promise<{ accessToken: string, refreshToken: string, expiresAt: number|null }|null>}
131
+ */
132
+ function refreshOAuthToken(refreshToken) {
133
+ return new Promise((resolve) => {
134
+ const body = JSON.stringify({
135
+ grant_type: 'refresh_token',
136
+ refresh_token: refreshToken,
137
+ client_id: OAUTH_CLIENT_ID,
138
+ scope: OAUTH_DEFAULT_SCOPES,
139
+ });
140
+
141
+ const url = new URL(OAUTH_TOKEN_ENDPOINT);
142
+ const req = https.request({
143
+ hostname: url.hostname,
144
+ path: url.pathname,
145
+ method: 'POST',
146
+ headers: {
147
+ 'Content-Type': 'application/json',
148
+ 'Content-Length': Buffer.byteLength(body),
149
+ },
150
+ timeout: 10000,
151
+ }, (res) => {
152
+ let data = '';
153
+ res.on('data', (chunk) => { data += chunk; });
154
+ res.on('end', () => {
155
+ if (res.statusCode === 200) {
156
+ try {
157
+ const parsed = JSON.parse(data);
158
+ if (parsed.access_token) {
159
+ resolve({
160
+ accessToken: parsed.access_token,
161
+ refreshToken: parsed.refresh_token || refreshToken,
162
+ expiresAt: parsed.expires_in
163
+ ? Date.now() + parsed.expires_in * 1000
164
+ : null,
165
+ });
166
+ return;
167
+ }
168
+ } catch (_err) {
169
+ // Fall through to resolve(null)
170
+ }
171
+ }
172
+ resolve(null);
173
+ });
174
+ });
175
+
176
+ req.on('error', () => resolve(null));
177
+ req.on('timeout', () => { req.destroy(); resolve(null); });
178
+
179
+ req.write(body);
180
+ req.end();
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Persist refreshed tokens back to the credentials file.
186
+ * @param {{ accessToken: string, refreshToken: string, expiresAt: number|null }} tokens
187
+ */
188
+ function persistRefreshedTokens(tokens) {
189
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
190
+ const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
191
+ const raw = fs.readFileSync(credentialsPath, 'utf8');
192
+ const credentials = JSON.parse(raw);
193
+ credentials.claudeAiOauth = {
194
+ ...credentials.claudeAiOauth,
195
+ accessToken: tokens.accessToken,
196
+ refreshToken: tokens.refreshToken,
197
+ ...(tokens.expiresAt && { expiresAt: tokens.expiresAt }),
198
+ };
199
+ fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2), 'utf8');
200
+ }
201
+
202
+ module.exports = {
203
+ readCredentials,
204
+ isTokenExpired,
205
+ refreshOAuthToken,
206
+ persistRefreshedTokens,
207
+ OAUTH_TOKEN_ENDPOINT,
208
+ OAUTH_CLIENT_ID,
209
+ TOKEN_EXPIRY_BUFFER_MS,
210
+ };