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,388 @@
|
|
|
1
|
+
const { BaseAgent } = require('./base.js');
|
|
2
|
+
const logger = require('../logger.js');
|
|
3
|
+
const { pingKeepalive } = require('./keepalive-helper.js');
|
|
4
|
+
const { parseApiResponse } = require('./api-response-parser.js');
|
|
5
|
+
const { parsePtyOutput } = require('./pty-output-parser.js');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const {
|
|
8
|
+
readCredentials, isTokenExpired, refreshOAuthToken, persistRefreshedTokens,
|
|
9
|
+
} = require('./oauth-helper.js');
|
|
10
|
+
|
|
11
|
+
// API response sentinel for parseOutput to detect
|
|
12
|
+
const API_RESPONSE_SENTINEL = '__API_RESPONSE__';
|
|
13
|
+
|
|
14
|
+
class ClaudeAgent extends BaseAgent {
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
super('claude', 'claude');
|
|
17
|
+
this._statusSent = false;
|
|
18
|
+
this.useApi = options.useApi || false;
|
|
19
|
+
this._apiResponse = null; // Stores the API response when using direct API
|
|
20
|
+
// PTY default: 600s (10 minutes), API mode: 60s (1 minute)
|
|
21
|
+
this.minRefreshInterval = this.useApi ? 60 : 600;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve OAuth token from various sources:
|
|
26
|
+
* 1. CLAUDE_CODE_OAUTH_TOKEN environment variable
|
|
27
|
+
* 2. ~/.claude/.credentials.json file
|
|
28
|
+
* 3. macOS Keychain (via security command)
|
|
29
|
+
* 4. Linux secret-tool
|
|
30
|
+
* @returns {Promise<string|null>} The OAuth token or null
|
|
31
|
+
*/
|
|
32
|
+
async _getAuthToken() {
|
|
33
|
+
const creds = readCredentials();
|
|
34
|
+
if (!creds) {
|
|
35
|
+
logger.agent(this.name, 'No OAuth token found in any credential source');
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
logger.agent(this.name, `Using OAuth token from ${creds.source}`);
|
|
39
|
+
if (!isTokenExpired(creds.expiresAt)) return creds.accessToken;
|
|
40
|
+
|
|
41
|
+
// Token is expired — try to refresh
|
|
42
|
+
logger.agent(this.name, 'Access token expired, attempting refresh...');
|
|
43
|
+
return this._refreshAndGetToken();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Make an HTTP GET request to the usage API with the given token.
|
|
48
|
+
* @param {string} token - OAuth access token
|
|
49
|
+
* @param {number} timeout - Request timeout in milliseconds
|
|
50
|
+
* @returns {Promise<{ statusCode: number, body: string }>}
|
|
51
|
+
*/
|
|
52
|
+
_fetchUsageApi(token, timeout) {
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const req = https.request('https://api.anthropic.com/api/oauth/usage', {
|
|
55
|
+
method: 'GET',
|
|
56
|
+
headers: {
|
|
57
|
+
'Authorization': `Bearer ${token}`,
|
|
58
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
'User-Agent': 'agent-tank/1.0',
|
|
61
|
+
},
|
|
62
|
+
timeout,
|
|
63
|
+
}, (res) => {
|
|
64
|
+
let data = '';
|
|
65
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
66
|
+
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
|
67
|
+
});
|
|
68
|
+
req.on('error', (err) => resolve({ statusCode: 0, body: err.message }));
|
|
69
|
+
req.on('timeout', () => { req.destroy(); resolve({ statusCode: 0, body: 'timeout' }); });
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch usage data directly from the Anthropic OAuth usage API.
|
|
76
|
+
* On 401, attempts a token refresh and retries once.
|
|
77
|
+
* @param {number} timeout - Request timeout in milliseconds
|
|
78
|
+
* @returns {Promise<Object|null>} The API response or null on failure
|
|
79
|
+
*/
|
|
80
|
+
async _runWithApi(timeout = 10000) {
|
|
81
|
+
const token = await this._getAuthToken();
|
|
82
|
+
if (!token) {
|
|
83
|
+
logger.agent(this.name, 'No OAuth token available for API fetch');
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await this._fetchUsageApi(token, timeout);
|
|
88
|
+
|
|
89
|
+
if (result.statusCode === 200) {
|
|
90
|
+
return this._parseUsageResponse(result.body);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// On 401, try refreshing the token and retry once
|
|
94
|
+
if (result.statusCode === 401) {
|
|
95
|
+
logger.agent(this.name, 'API returned 401, attempting token refresh...');
|
|
96
|
+
const refreshedToken = await this._refreshAndGetToken();
|
|
97
|
+
if (refreshedToken) {
|
|
98
|
+
const retry = await this._fetchUsageApi(refreshedToken, timeout);
|
|
99
|
+
if (retry.statusCode === 200) {
|
|
100
|
+
return this._parseUsageResponse(retry.body);
|
|
101
|
+
}
|
|
102
|
+
logger.agent(this.name, `API retry failed (${retry.statusCode})`);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (result.statusCode === 0) {
|
|
108
|
+
logger.agent(this.name, 'API request error:', result.body);
|
|
109
|
+
} else {
|
|
110
|
+
logger.agent(this.name, `API request failed with status ${result.statusCode}`);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse the usage API JSON response.
|
|
117
|
+
* @param {string} body - Raw response body
|
|
118
|
+
* @returns {Object|null}
|
|
119
|
+
*/
|
|
120
|
+
_parseUsageResponse(body) {
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(body);
|
|
123
|
+
logger.agent(this.name, 'API response received successfully');
|
|
124
|
+
return parsed;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
logger.agent(this.name, 'Failed to parse API response:', err.message);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Force-refresh the OAuth token (regardless of expiresAt) and return the new access token.
|
|
133
|
+
* @returns {Promise<string|null>}
|
|
134
|
+
*/
|
|
135
|
+
async _refreshAndGetToken() {
|
|
136
|
+
const creds = readCredentials();
|
|
137
|
+
if (!creds?.refreshToken) {
|
|
138
|
+
logger.agent(this.name, 'No refresh token available');
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const refreshed = await refreshOAuthToken(creds.refreshToken);
|
|
143
|
+
if (!refreshed) {
|
|
144
|
+
logger.agent(this.name, 'Token refresh failed');
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
logger.agent(this.name, 'OAuth token refreshed successfully');
|
|
149
|
+
if (creds.source === 'credentials_file') {
|
|
150
|
+
try {
|
|
151
|
+
persistRefreshedTokens(refreshed);
|
|
152
|
+
logger.agent(this.name, 'Refreshed tokens persisted to credentials file');
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.agent(this.name, 'Failed to persist refreshed tokens:', err.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return refreshed.accessToken;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Override runCommand to attempt API fetch first when useApi is enabled
|
|
162
|
+
* Falls back to PTY if API fails
|
|
163
|
+
*/
|
|
164
|
+
async runCommand() {
|
|
165
|
+
if (this.useApi) {
|
|
166
|
+
logger.agent(this.name, 'Attempting direct API fetch...');
|
|
167
|
+
const apiResponse = await this._runWithApi();
|
|
168
|
+
if (apiResponse) {
|
|
169
|
+
this._apiResponse = apiResponse;
|
|
170
|
+
return API_RESPONSE_SENTINEL;
|
|
171
|
+
}
|
|
172
|
+
logger.agent(this.name, 'API fetch failed, falling back to PTY');
|
|
173
|
+
// Reset minRefreshInterval to PTY default for fallback
|
|
174
|
+
this.minRefreshInterval = 600;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fall back to PTY command execution
|
|
178
|
+
return super.runCommand();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getTimeout() { return 30000; }
|
|
182
|
+
getEnv() {
|
|
183
|
+
const env = { ...process.env, TERM: 'dumb', NO_COLOR: '1' };
|
|
184
|
+
delete env.CLAUDECODE; // Allow spawning inside a Claude Code session
|
|
185
|
+
return env;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
isReadyForCommands(output) {
|
|
189
|
+
const clean = this.stripAnsi(output);
|
|
190
|
+
return clean.includes('? for shortcuts') || clean.includes('❯') ||
|
|
191
|
+
clean.includes('> ') || clean.includes('Try "');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleTrustPrompt(shell, output) {
|
|
195
|
+
const patterns = ['Do you trust', 'trust the files', 'trust this folder', 'Trust this workspace', 'allow access'];
|
|
196
|
+
if (!patterns.some(p => output.toLowerCase().includes(p.toLowerCase()))) return false;
|
|
197
|
+
logger.agent(this.name, 'Detected trust prompt, auto-accepting...');
|
|
198
|
+
shell.write('y\r');
|
|
199
|
+
setTimeout(() => { logger.agent(this.name, 'Sending Enter to proceed...'); shell.write('\r'); }, 500);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
hasCompleteOutput(output) {
|
|
204
|
+
const clean = this.stripAnsi(output);
|
|
205
|
+
// Detect error responses (rate limiting, session errors) — treat as complete
|
|
206
|
+
if (/rate.?limited|rate_limit_error|Failed to load usage|session.?expired|session.?error|invalid.?session|authentication.?error|auth.?failed|Unable to (?:load|fetch)|Error loading|could not (?:load|fetch)|not authenticated|login required|sign.?in required/i.test(clean)) return true;
|
|
207
|
+
// Basic requirements - must have actual usage data, not just the loading screen
|
|
208
|
+
const hasSession = clean.includes('Current session');
|
|
209
|
+
const hasWeekly = clean.includes('Current week');
|
|
210
|
+
const hasPercentUsed = clean.includes('% used');
|
|
211
|
+
if (!hasSession || !hasWeekly || !hasPercentUsed) return false;
|
|
212
|
+
|
|
213
|
+
// If "all models" section exists, wait for "Sonnet only" section to render
|
|
214
|
+
const hasAllModels = /Current\s+week\s*\(?\s*all\s+models/i.test(clean);
|
|
215
|
+
if (hasAllModels) {
|
|
216
|
+
const hasSonnetOnly = /Current\s+week\s*\(?\s*Sonnet\s+only/i.test(clean);
|
|
217
|
+
if (!hasSonnetOnly) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
// Both sections exist, check we have at least one timezone per section
|
|
221
|
+
const allModelsIdx = clean.search(/Current\s+week\s*\(?\s*all\s+models/i);
|
|
222
|
+
const sonnetOnlyIdx = clean.search(/Current\s+week\s*\(?\s*Sonnet\s+only/i);
|
|
223
|
+
const allModelsSection = clean.substring(allModelsIdx, sonnetOnlyIdx);
|
|
224
|
+
const sonnetSection = clean.substring(sonnetOnlyIdx);
|
|
225
|
+
|
|
226
|
+
const allModelsHasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(allModelsSection);
|
|
227
|
+
const sonnetHasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(sonnetSection);
|
|
228
|
+
|
|
229
|
+
return allModelsHasTimezone && sonnetHasTimezone;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Fallback for legacy format (single "Current week" without model qualifiers)
|
|
233
|
+
// Check for timezone pattern which indicates reset times are fully loaded
|
|
234
|
+
const hasTimezone = /\([A-Za-z]+\/[A-Za-z_]+\)/.test(clean);
|
|
235
|
+
return hasTimezone;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
sendCommands(shell, _output) {
|
|
239
|
+
logger.agent(this.name, 'Sending /usage command...');
|
|
240
|
+
// Escape dismisses any previous output/UI, then type command + Enter
|
|
241
|
+
setTimeout(() => shell.write('\x1b'), 50);
|
|
242
|
+
setTimeout(() => shell.write('/usage'), 600);
|
|
243
|
+
setTimeout(() => shell.write('\r'), 1000);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// After getting /usage output, dismiss dialog so next refresh starts with clean prompt
|
|
247
|
+
async sendCommandAndWait() {
|
|
248
|
+
const result = await super.sendCommandAndWait();
|
|
249
|
+
if (this.shell) { this.shell.write('\x1b'); await new Promise(r => setTimeout(r, 1000)); this.output = ''; }
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Parse the direct API response into the unified usage schema
|
|
255
|
+
* Delegates to the external api-response-parser module
|
|
256
|
+
* @param {Object} apiResponse - The raw API response from Anthropic
|
|
257
|
+
* @returns {Object} The parsed usage object matching the PTY format
|
|
258
|
+
*/
|
|
259
|
+
_parseApiResponse(apiResponse) {
|
|
260
|
+
return parseApiResponse(apiResponse);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
parseOutput(output) {
|
|
264
|
+
// Check if this is an API response (sentinel marker)
|
|
265
|
+
if (output === API_RESPONSE_SENTINEL && this._apiResponse) {
|
|
266
|
+
logger.agent(this.name, 'Parsing API response');
|
|
267
|
+
const usage = this._parseApiResponse(this._apiResponse);
|
|
268
|
+
this._apiResponse = null; // Clear after parsing
|
|
269
|
+
return usage;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const clean = this.stripAnsi(output);
|
|
273
|
+
|
|
274
|
+
// Debug: log session section for troubleshooting
|
|
275
|
+
const sessionIdx = clean.indexOf('Current session');
|
|
276
|
+
const weeklyIdx = clean.indexOf('Current week');
|
|
277
|
+
if (sessionIdx !== -1 && weeklyIdx !== -1) {
|
|
278
|
+
logger.agent(this.name, 'Session section preview:', logger.dim(clean.substring(sessionIdx, weeklyIdx).substring(0, 200)));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Delegate to PTY output parser module
|
|
282
|
+
return parsePtyOutput(clean);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Fetch metadata by sending /status command once on first refresh
|
|
286
|
+
async fetchMetadata() {
|
|
287
|
+
if (this._statusSent) {
|
|
288
|
+
return this.metadata;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Ensure process is spawned and ready
|
|
292
|
+
if (!this.shell || !this.processReady) {
|
|
293
|
+
await this.spawnProcess();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return new Promise((resolve, _reject) => {
|
|
297
|
+
let statusOutput = '';
|
|
298
|
+
let completed = false;
|
|
299
|
+
|
|
300
|
+
const finish = (result) => {
|
|
301
|
+
if (completed) return;
|
|
302
|
+
completed = true;
|
|
303
|
+
this._statusSent = true;
|
|
304
|
+
this._onDataCallback = null;
|
|
305
|
+
this._commandInFlight = false;
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
resolve(result);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
logger.agent(this.name, '/status timeout, using partial output');
|
|
312
|
+
finish(this._parseStatusOutput(statusOutput));
|
|
313
|
+
}, 10000); // 10 second timeout for /status
|
|
314
|
+
|
|
315
|
+
this._commandInFlight = true;
|
|
316
|
+
this._onDataCallback = () => {
|
|
317
|
+
statusOutput = this.output;
|
|
318
|
+
// Check if we have complete /status output
|
|
319
|
+
if (this._hasCompleteStatusOutput(statusOutput)) {
|
|
320
|
+
logger.agent(this.name, 'Complete /status output detected');
|
|
321
|
+
setTimeout(() => finish(this._parseStatusOutput(statusOutput)), 100);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
logger.agent(this.name, 'Sending /status command for metadata...');
|
|
326
|
+
this.output = '';
|
|
327
|
+
// Send escape first to dismiss any UI, then /status
|
|
328
|
+
setTimeout(() => this.shell.write('\x1b'), 50);
|
|
329
|
+
setTimeout(() => this.shell.write('/status'), 600);
|
|
330
|
+
setTimeout(() => this.shell.write('\r'), 1000);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_hasCompleteStatusOutput(output) {
|
|
335
|
+
const clean = this.stripAnsi(output);
|
|
336
|
+
const hasSessionInfo = /session/i.test(clean) || /working directory|cwd/i.test(clean);
|
|
337
|
+
const hasPrompt = ['? for shortcuts', '❯', '> ', 'esc to'].some(p => clean.includes(p));
|
|
338
|
+
return hasSessionInfo && hasPrompt;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_parseStatusOutput(output) {
|
|
342
|
+
const clean = this.stripAnsi(output);
|
|
343
|
+
const metadata = {};
|
|
344
|
+
const patterns = [
|
|
345
|
+
['sessionId', /Session(?:\s+ID)?:\s*([a-f0-9-]+)/i],
|
|
346
|
+
['cwd', /(?:Working directory|Cwd|Current directory|Directory):\s*([^\n│]+)/i],
|
|
347
|
+
['organization', /(?:Organization|Org):\s*([^\n│]+)/i],
|
|
348
|
+
['email', /(?:Email|Account|User|Logged in as):\s*(\S+@\S+)/i],
|
|
349
|
+
['model', /(?:Model|Using model):\s*(claude[-\w.]+)/i],
|
|
350
|
+
['version', /(?:Version|Claude Code):\s*v?([\d.]+)/i],
|
|
351
|
+
];
|
|
352
|
+
for (const [key, regex] of patterns) {
|
|
353
|
+
const match = clean.match(regex);
|
|
354
|
+
if (match) metadata[key] = this.stripBoxChars(match[1]);
|
|
355
|
+
}
|
|
356
|
+
return Object.keys(metadata).length > 0 ? metadata : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Lightweight keepalive to prevent session expiration. @returns {Promise<boolean>} True if keepalive succeeded */
|
|
360
|
+
async keepalive() {
|
|
361
|
+
if (this.freshProcess) { console.log(`[${this.name}] Keepalive skipped (fresh process mode)`); return true; }
|
|
362
|
+
if (!this.shell || !this.processReady) { console.log(`[${this.name}] Keepalive: spawning process...`); await this.spawnProcess(); }
|
|
363
|
+
if (this.shell) { console.log(`[${this.name}] Keepalive: sending ping...`); this.shell.write('\x1b'); return true; }
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Spawns fresh CLI, sends /status to refresh session, then tears down cleanly. @returns {Promise<boolean>} */
|
|
368
|
+
async pingKeepalive() {
|
|
369
|
+
return pingKeepalive({
|
|
370
|
+
name: this.name,
|
|
371
|
+
command: this.command,
|
|
372
|
+
args: this.args,
|
|
373
|
+
env: this.getEnv(),
|
|
374
|
+
termName: 'xterm-color',
|
|
375
|
+
isReady: (output) => this.isReadyForCommands(output),
|
|
376
|
+
sendCommand: (shell) => {
|
|
377
|
+
setTimeout(() => shell.write('\x1b'), 50);
|
|
378
|
+
setTimeout(() => shell.write('/status'), 300);
|
|
379
|
+
setTimeout(() => shell.write('\r'), 500);
|
|
380
|
+
},
|
|
381
|
+
isComplete: (output) => this._hasCompleteStatusOutput(output),
|
|
382
|
+
handlePrompts: (shell, _data, output) => this.handleTrustPrompt(shell, output),
|
|
383
|
+
respondToTerminalQueries: (data, shell) => this._respondToTerminalQueries(data, shell),
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
module.exports = { ClaudeAgent };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { calculatePace } = require('../pace-evaluator.js');
|
|
2
|
+
const { CYCLE_DURATIONS } = require('../usage-formatters.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse reset time string to seconds and formatted text
|
|
6
|
+
* @param {string} resetStr - Reset time string (e.g., "14:30" or "14:30 on 20 Mar")
|
|
7
|
+
* @returns {Object|null} - { text, seconds } or null if invalid
|
|
8
|
+
*/
|
|
9
|
+
function parseResetTime(resetStr) {
|
|
10
|
+
if (!resetStr) return null;
|
|
11
|
+
|
|
12
|
+
const now = new Date();
|
|
13
|
+
let resetDate;
|
|
14
|
+
|
|
15
|
+
// Format: "HH:MM" (today) or "HH:MM on DD Mon"
|
|
16
|
+
const timeOnDateMatch = resetStr.match(/(\d{1,2}):(\d{2})\s*on\s*(\d{1,2})\s*(\w+)/i);
|
|
17
|
+
const timeOnlyMatch = resetStr.match(/^(\d{1,2}):(\d{2})$/);
|
|
18
|
+
|
|
19
|
+
if (timeOnDateMatch) {
|
|
20
|
+
const [, hours, minutes, day, month] = timeOnDateMatch;
|
|
21
|
+
const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
22
|
+
const monthIndex = monthNames.indexOf(month.toLowerCase().substring(0, 3));
|
|
23
|
+
resetDate = new Date(now.getFullYear(), monthIndex, parseInt(day), parseInt(hours), parseInt(minutes));
|
|
24
|
+
// If the date is in the past, it's next year
|
|
25
|
+
if (resetDate < now) {
|
|
26
|
+
resetDate.setFullYear(resetDate.getFullYear() + 1);
|
|
27
|
+
}
|
|
28
|
+
} else if (timeOnlyMatch) {
|
|
29
|
+
const [, hours, minutes] = timeOnlyMatch;
|
|
30
|
+
resetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), parseInt(hours), parseInt(minutes));
|
|
31
|
+
// If time is in the past, it's tomorrow
|
|
32
|
+
if (resetDate < now) {
|
|
33
|
+
resetDate.setDate(resetDate.getDate() + 1);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
return { text: resetStr, seconds: null }; // Return original if can't parse
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const diffMs = resetDate - now;
|
|
40
|
+
if (diffMs <= 0) return { text: 'soon', seconds: 0 };
|
|
41
|
+
|
|
42
|
+
const diffSeconds = Math.floor(diffMs / 1000);
|
|
43
|
+
const diffMins = Math.floor(diffSeconds / 60);
|
|
44
|
+
const hours = Math.floor(diffMins / 60);
|
|
45
|
+
const mins = diffMins % 60;
|
|
46
|
+
|
|
47
|
+
let text;
|
|
48
|
+
if (hours > 24) {
|
|
49
|
+
const days = Math.floor(hours / 24);
|
|
50
|
+
const remainingHours = hours % 24;
|
|
51
|
+
text = `${days}d ${remainingHours}h`;
|
|
52
|
+
} else if (hours > 0) {
|
|
53
|
+
text = `${hours}h ${mins}m`;
|
|
54
|
+
} else {
|
|
55
|
+
text = `${mins}m`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { text, seconds: diffSeconds };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse a limit entry from PTY output match
|
|
63
|
+
* @param {Array} match - Regex match array [fullMatch, percent, resetTime]
|
|
64
|
+
* @param {string} label - Label for the limit
|
|
65
|
+
* @param {string} cycleType - Cycle type for pace calculation
|
|
66
|
+
* @returns {Object} - Parsed limit entry
|
|
67
|
+
*/
|
|
68
|
+
function parseLimitEntry(match, label, cycleType) {
|
|
69
|
+
const percentLeft = parseFloat(match[1]);
|
|
70
|
+
const resetsAt = match[2].trim();
|
|
71
|
+
const resetData = parseResetTime(resetsAt);
|
|
72
|
+
const percentUsed = 100 - percentLeft;
|
|
73
|
+
const resetsInSeconds = resetData?.seconds || null;
|
|
74
|
+
|
|
75
|
+
const entry = {
|
|
76
|
+
percentLeft,
|
|
77
|
+
resetsAt,
|
|
78
|
+
label,
|
|
79
|
+
percentUsed,
|
|
80
|
+
resetsIn: resetData?.text || null,
|
|
81
|
+
resetsInSeconds,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const cycleDuration = CYCLE_DURATIONS[cycleType];
|
|
85
|
+
if (cycleDuration && resetsInSeconds != null) {
|
|
86
|
+
const paceData = calculatePace({
|
|
87
|
+
usagePercent: percentUsed,
|
|
88
|
+
resetsInSeconds,
|
|
89
|
+
cycleDurationSeconds: cycleDuration,
|
|
90
|
+
});
|
|
91
|
+
if (paceData) entry.pace = paceData;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse version info from output string
|
|
99
|
+
* @param {string} output - Raw or clean output
|
|
100
|
+
* @param {Function} stripAnsi - Function to strip ANSI codes
|
|
101
|
+
* @returns {Object|null} - Version info object or null
|
|
102
|
+
*/
|
|
103
|
+
function parseVersionInfo(output, stripAnsi) {
|
|
104
|
+
const clean = stripAnsi ? stripAnsi(output) : output;
|
|
105
|
+
const versionInfo = {};
|
|
106
|
+
|
|
107
|
+
const updateMatch = clean.match(/([\d.]+)\s*->\s*([\d.]+)/);
|
|
108
|
+
if (updateMatch) {
|
|
109
|
+
versionInfo.current = updateMatch[1];
|
|
110
|
+
versionInfo.latest = updateMatch[2];
|
|
111
|
+
return versionInfo;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentMatch = clean.match(/OpenAI Codex[^(]*\(v?([\d.]+)\)/i);
|
|
115
|
+
if (currentMatch) versionInfo.current = currentMatch[1];
|
|
116
|
+
|
|
117
|
+
const patterns = [
|
|
118
|
+
/v?([\d.]+)\s*(?:is\s+)?available/i,
|
|
119
|
+
/new version[:\s]+v?([\d.]+)/i,
|
|
120
|
+
/update to v?([\d.]+)/i,
|
|
121
|
+
/latest[:\s]+v?([\d.]+)/i,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const p of patterns) {
|
|
125
|
+
const m = clean.match(p);
|
|
126
|
+
if (m && m[1] !== versionInfo.current) {
|
|
127
|
+
versionInfo.latest = m[1];
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.keys(versionInfo).length > 0 ? versionInfo : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse model-specific limit sections from output
|
|
137
|
+
* @param {string} clean - Cleaned output string
|
|
138
|
+
* @returns {Array} - Array of model limit entries
|
|
139
|
+
*/
|
|
140
|
+
function parseModelLimits(clean) {
|
|
141
|
+
const modelHeaderRegex = /([\w][\w.-]+)\s+limit:\s*[│╮╯]?/gi;
|
|
142
|
+
const limitRegex = /5h limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s*([^)]+)\)/i;
|
|
143
|
+
const weeklyLimitRegex = /Weekly limit:\s*\[.*?\]\s*(\d+)%\s*left\s*\(resets\s*([^)]+)\)/i;
|
|
144
|
+
|
|
145
|
+
const modelLimits = [];
|
|
146
|
+
let headerMatch;
|
|
147
|
+
|
|
148
|
+
while ((headerMatch = modelHeaderRegex.exec(clean)) !== null) {
|
|
149
|
+
const name = headerMatch[1];
|
|
150
|
+
if (/^(5h|Weekly)$/i.test(name)) continue;
|
|
151
|
+
|
|
152
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
153
|
+
const remaining = clean.substring(sectionStart);
|
|
154
|
+
const nextHeader = remaining.search(/[\w][\w.-]*-[\w.-]+\s+limit:/i);
|
|
155
|
+
const endBox = remaining.indexOf('╰');
|
|
156
|
+
let sectionEnd = remaining.length;
|
|
157
|
+
if (nextHeader > 0 && (endBox < 0 || nextHeader < endBox)) sectionEnd = nextHeader;
|
|
158
|
+
else if (endBox > 0) sectionEnd = endBox;
|
|
159
|
+
const content = remaining.substring(0, sectionEnd);
|
|
160
|
+
|
|
161
|
+
const entry = { name };
|
|
162
|
+
const fh = content.match(limitRegex);
|
|
163
|
+
if (fh) {
|
|
164
|
+
entry.fiveHour = parseLimitEntry(fh, '5h limit', 'fiveHour');
|
|
165
|
+
}
|
|
166
|
+
const wk = content.match(weeklyLimitRegex);
|
|
167
|
+
if (wk) {
|
|
168
|
+
entry.weekly = parseLimitEntry(wk, 'Weekly limit', 'weekly');
|
|
169
|
+
}
|
|
170
|
+
if (entry.fiveHour || entry.weekly) {
|
|
171
|
+
modelLimits.push(entry);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return modelLimits;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract metadata from /status output
|
|
180
|
+
* @param {string} clean - Cleaned output string
|
|
181
|
+
* @param {Function} stripBoxChars - Function to strip box characters
|
|
182
|
+
* @returns {Object} - Metadata object
|
|
183
|
+
*/
|
|
184
|
+
function extractMetadataFromOutput(clean, stripBoxChars) {
|
|
185
|
+
const metadata = {};
|
|
186
|
+
|
|
187
|
+
// Extract directory/cwd
|
|
188
|
+
const dirMatch = clean.match(/(?:Directory|Working directory|Cwd|Current directory):\s*([^\n│]+)/i);
|
|
189
|
+
if (dirMatch) {
|
|
190
|
+
metadata.directory = stripBoxChars(dirMatch[1]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Extract session ID
|
|
194
|
+
const sessionMatch = clean.match(/Session(?:\s+ID)?:\s*([a-f0-9-]+)/i);
|
|
195
|
+
if (sessionMatch) {
|
|
196
|
+
metadata.sessionId = stripBoxChars(sessionMatch[1]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Extract collaboration mode (solo/team/etc)
|
|
200
|
+
const collabMatch = clean.match(/(?:Collaboration|Mode):\s*(\w+)/i);
|
|
201
|
+
if (collabMatch) {
|
|
202
|
+
metadata.collaborationMode = stripBoxChars(collabMatch[1]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Extract model
|
|
206
|
+
const modelMatch = clean.match(/Model:\s*([\w.-]+)/i);
|
|
207
|
+
if (modelMatch) {
|
|
208
|
+
metadata.model = stripBoxChars(modelMatch[1]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Extract account/email
|
|
212
|
+
const accountMatch = clean.match(/Account:\s*(\S+@\S+)/i);
|
|
213
|
+
if (accountMatch) {
|
|
214
|
+
metadata.email = stripBoxChars(accountMatch[1]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return metadata;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
parseResetTime,
|
|
222
|
+
parseLimitEntry,
|
|
223
|
+
parseVersionInfo,
|
|
224
|
+
parseModelLimits,
|
|
225
|
+
extractMetadataFromOutput,
|
|
226
|
+
};
|