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.
- package/LICENSE +21 -0
- package/README.md +675 -0
- package/bin/agent-tank.js +301 -0
- package/media/www-preview.png +0 -0
- package/package.json +65 -0
- package/src/activity-monitor.js +312 -0
- package/src/agents/api-response-parser.js +255 -0
- package/src/agents/base.js +396 -0
- package/src/agents/claude.js +388 -0
- package/src/agents/codex-pty-helpers.js +226 -0
- package/src/agents/codex-rpc-helpers.js +167 -0
- package/src/agents/codex.js +383 -0
- package/src/agents/gemini.js +302 -0
- package/src/agents/keepalive-helper.js +115 -0
- package/src/agents/oauth-helper.js +210 -0
- package/src/agents/pty-output-parser.js +301 -0
- package/src/auto-refresh-manager.js +246 -0
- package/src/client-auto-refresh-helpers.js +143 -0
- package/src/client-auto-refresh.js +321 -0
- package/src/client-favicon.js +198 -0
- package/src/client-monitor.js +242 -0
- package/src/client-script.js +147 -0
- package/src/discovery.js +128 -0
- package/src/history-store.js +273 -0
- package/src/http-handler.js +182 -0
- package/src/icons.js +67 -0
- package/src/index.js +376 -0
- package/src/json-rpc-client.js +285 -0
- package/src/keepalive-manager.js +163 -0
- package/src/logger.js +213 -0
- package/src/pace-attachment.js +104 -0
- package/src/pace-evaluator.js +269 -0
- package/src/public-status.js +276 -0
- package/src/server.js +144 -0
- package/src/snapshot-metrics.js +79 -0
- package/src/status-page.css +1149 -0
- package/src/status-page.js +128 -0
- package/src/usage-formatters.js +259 -0
|
@@ -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
|
+
};
|