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,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
+ };