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,301 @@
1
+ /**
2
+ * PTY Output Parser for Claude Agent
3
+ * Handles parsing of PTY terminal output from the Claude CLI /usage command
4
+ */
5
+
6
+ const { calculatePace } = require('../pace-evaluator.js');
7
+ const { CYCLE_DURATIONS } = require('../usage-formatters.js');
8
+
9
+ /**
10
+ * Convert 12-hour time to 24-hour
11
+ * @param {string} hourRaw - Hour value as string
12
+ * @param {string} ampm - 'am' or 'pm'
13
+ * @returns {number} Hour in 24-hour format
14
+ */
15
+ function to24Hour(hourRaw, ampm) {
16
+ let hour = parseInt(hourRaw);
17
+ if (ampm.toLowerCase() === 'pm' && hour !== 12) hour += 12;
18
+ if (ampm.toLowerCase() === 'am' && hour === 12) hour = 0;
19
+ return hour;
20
+ }
21
+
22
+ /**
23
+ * Format duration from seconds to readable text
24
+ * @param {number} diffSeconds - Duration in seconds
25
+ * @returns {string} Formatted duration string
26
+ */
27
+ function formatDuration(diffSeconds) {
28
+ const mins = Math.floor(diffSeconds / 60);
29
+ const hours = Math.floor(mins / 60);
30
+ const m = mins % 60;
31
+ if (hours > 24) {
32
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
33
+ }
34
+ return hours > 0 ? `${hours}h ${m}m` : `${m}m`;
35
+ }
36
+
37
+ /**
38
+ * Parse date with time: "Jan 22, 1pm" or date-only: "Apr 1"
39
+ * @param {string} cleanStr - Cleaned date string
40
+ * @param {Date} now - Current date/time
41
+ * @param {string[]} monthNames - Array of month name abbreviations
42
+ * @returns {Date|null} Parsed date or null
43
+ */
44
+ function parseDateTimeStr(cleanStr, now, monthNames) {
45
+ const dateTimeMatch = cleanStr.match(/(\w+)\s+(\d{1,2}),?\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
46
+ // 24-hour format with date: "10:00 on 15 Mar" or "14:30 on 22 Jan"
47
+ const time24DateMatch = !dateTimeMatch && cleanStr.match(/^(\d{1,2}):(\d{2})\s+on\s+(\d{1,2})\s+(\w+)$/i);
48
+ const dateOnlyMatch = !dateTimeMatch && !time24DateMatch && cleanStr.match(/(\w+)\s+(\d{1,2})$/i);
49
+ if (!dateTimeMatch && !time24DateMatch && !dateOnlyMatch) return null;
50
+
51
+ let resetDate;
52
+ if (dateTimeMatch) {
53
+ const [, month, day, hourRaw, minutes = '0', ampm] = dateTimeMatch;
54
+ const monthIndex = monthNames.indexOf(month.toLowerCase().substring(0, 3));
55
+ if (monthIndex === -1) return null;
56
+ resetDate = new Date(now.getFullYear(), monthIndex, parseInt(day), to24Hour(hourRaw, ampm), parseInt(minutes));
57
+ } else if (time24DateMatch) {
58
+ const [, hours, minutes, day, month] = time24DateMatch;
59
+ const monthIndex = monthNames.indexOf(month.toLowerCase().substring(0, 3));
60
+ if (monthIndex === -1) return null;
61
+ resetDate = new Date(now.getFullYear(), monthIndex, parseInt(day), parseInt(hours), parseInt(minutes));
62
+ } else {
63
+ const [, month, day] = dateOnlyMatch;
64
+ const monthIndex = monthNames.indexOf(month.toLowerCase().substring(0, 3));
65
+ if (monthIndex === -1) return null;
66
+ resetDate = new Date(now.getFullYear(), monthIndex, parseInt(day));
67
+ }
68
+ if (resetDate < now) resetDate.setFullYear(resetDate.getFullYear() + 1);
69
+ return resetDate;
70
+ }
71
+
72
+ /**
73
+ * Convert reset timestamp to duration object with string and seconds
74
+ * @param {string} resetStr - Reset time string from PTY output
75
+ * @returns {Object|null} Object with text and seconds, or null
76
+ */
77
+ function parseResetTime(resetStr) {
78
+ if (!resetStr) return null;
79
+ const now = new Date();
80
+ const cleanStr = resetStr.replace(/\s*\([^)]+\)\s*$/, '').trim();
81
+ const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
82
+
83
+ let resetDate;
84
+ // Try time-only format first: "2:59am" or "12:30pm"
85
+ const timeOnlyMatch = cleanStr.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$/i);
86
+ // Try 24-hour time-only format: "14:30" or "23:00"
87
+ const time24Match = !timeOnlyMatch && cleanStr.match(/^(\d{1,2}):(\d{2})$/);
88
+ if (timeOnlyMatch) {
89
+ const [, hourRaw, minutes = '0', ampm] = timeOnlyMatch;
90
+ resetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), to24Hour(hourRaw, ampm), parseInt(minutes));
91
+ if (resetDate <= now) resetDate.setDate(resetDate.getDate() + 1);
92
+ } else if (time24Match) {
93
+ const [, hours, minutes] = time24Match;
94
+ resetDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), parseInt(hours), parseInt(minutes));
95
+ if (resetDate <= now) resetDate.setDate(resetDate.getDate() + 1);
96
+ } else {
97
+ resetDate = parseDateTimeStr(cleanStr, now, monthNames);
98
+ if (!resetDate) return { text: resetStr, seconds: null };
99
+ }
100
+
101
+ const diffMs = resetDate - now;
102
+ if (diffMs <= 0) return { text: 'soon', seconds: 0 };
103
+ const diffSeconds = Math.floor(diffMs / 1000);
104
+ return { text: formatDuration(diffSeconds), seconds: diffSeconds };
105
+ }
106
+
107
+ /**
108
+ * Extract section between two regex patterns from text
109
+ * @param {string} text - Full text to search
110
+ * @param {string} start - Start pattern
111
+ * @param {string} end - End pattern (optional)
112
+ * @returns {string|null} Extracted section or null
113
+ */
114
+ function extractSection(text, start, end) {
115
+ const regex = end ? new RegExp(`${start}([\\s\\S]*?)(?=${end}|$)`, 'i') : new RegExp(`${start}([\\s\\S]*)$`, 'i');
116
+ return text.match(regex)?.[1] || null;
117
+ }
118
+
119
+ /**
120
+ * Extract reset time string from a section (handles PTY corruption)
121
+ * @param {string} section - Section text
122
+ * @returns {string|null} Reset time string or null
123
+ */
124
+ function extractResetTime(section) {
125
+ // Look for date+time pattern FIRST (more specific)
126
+ const dateTimeMatch = section.match(/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s*\d{1,2}(?::\d{2})?\s*(am|pm))\s*\(([^)]+)\)/i);
127
+ if (dateTimeMatch) return `${dateTimeMatch[1]} (${dateTimeMatch[3]})`;
128
+ // Try clean time-only pattern
129
+ const timeOnlyMatch = section.match(/(\d{1,2}(?::\d{2})?\s*(?:am|pm))\s*\(([^)]+)\)/i);
130
+ if (timeOnlyMatch) return `${timeOnlyMatch[1]} (${timeOnlyMatch[2]})`;
131
+ // Handle PTY-corrupted time: "1 m" or "2 59 m" (missing a/p)
132
+ const corruptedMatch = section.match(/(\d{1,2})(?:\s*:?\s*(\d{2}))?\s+m\s*\(([^)]+)\)/i);
133
+ if (corruptedMatch) {
134
+ const [, hour, minutes, tz] = corruptedMatch;
135
+ const mins = minutes ? `:${minutes}` : '';
136
+ const am = `${hour}${mins}am (${tz})`;
137
+ const pm = `${hour}${mins}pm (${tz})`;
138
+ const amSec = parseResetTime(am)?.seconds ?? Infinity;
139
+ const pmSec = parseResetTime(pm)?.seconds ?? Infinity;
140
+ return amSec <= pmSec ? am : pm;
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Parse a usage section for percent and reset time
147
+ * @param {string|null} section - Section text
148
+ * @returns {Object|null} Parsed section data or null
149
+ */
150
+ function parseUsageSection(section) {
151
+ if (!section) return null;
152
+ const percentMatch = section.match(/(\d+)\s*%\s*used/i);
153
+ if (!percentMatch) return null;
154
+ const resetsAt = extractResetTime(section);
155
+ const resetData = resetsAt ? parseResetTime(resetsAt) : null;
156
+ return {
157
+ percent: parseFloat(percentMatch[1]),
158
+ resetsAt,
159
+ resetsIn: resetData?.text || null,
160
+ resetsInSeconds: resetData?.seconds || null,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Parse extra usage section with budget info
166
+ * @param {string} extraSection - Extra usage section text
167
+ * @returns {Object|null} Parsed extra usage data or null
168
+ */
169
+ function parseExtraUsage(extraSection) {
170
+ const percentMatch = extraSection.match(/(\d+)\s*%\s*used/i);
171
+ const spentMatch = extraSection.match(/\$([0-9.]+)\s*\/\s*\$([0-9.]+)\s*spent/i);
172
+ if (!percentMatch && !spentMatch) return null;
173
+ const dateMatch = extraSection.match(/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s*(?:\d{1,2}(?::\d{2})?\s*(?:am|pm))?)\s*\(([^)]+)\)/i);
174
+ const resetsAt = dateMatch ? `${dateMatch[1].trim()} (${dateMatch[2]})` : null;
175
+ const resetData = resetsAt ? parseResetTime(resetsAt) : null;
176
+ return {
177
+ label: 'Extra usage',
178
+ percent: percentMatch ? parseFloat(percentMatch[1]) : null,
179
+ spent: spentMatch ? parseFloat(spentMatch[1]) : null,
180
+ budget: spentMatch ? parseFloat(spentMatch[2]) : null,
181
+ resetsAt,
182
+ resetsIn: resetData?.text || null,
183
+ resetsInSeconds: resetData?.seconds || null,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Parse legacy weekly format (single "Current week" without model qualifiers)
189
+ * @param {string} clean - Cleaned output text
190
+ * @returns {Object|null} Parsed legacy weekly data or null
191
+ */
192
+ function parseLegacyWeekly(clean) {
193
+ const weeklyMatch = clean.match(/Current\s+week[\s\S]*?(\d+)\s*%\s*used[\s\S]*?Resets\s+([\s\S]*?)(?=esc\s+to\s+cancel|current\s+week|$)/i);
194
+ if (weeklyMatch) {
195
+ const resetSection = weeklyMatch[2];
196
+ const tzMatch = resetSection.match(/((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},?\s*\d{1,2}(?::\d{2})?\s*(?:am|pm)\s*\([^)]+\)|\d{1,2}(?::\d{2})?\s*(?:am|pm)\s*\([^)]+\))/i);
197
+ const resetsAt = tzMatch ? tzMatch[1].trim() : resetSection.trim().split(/\s{2,}/)[0];
198
+ const resetData = parseResetTime(resetsAt);
199
+ return {
200
+ percent: parseFloat(weeklyMatch[1]),
201
+ label: 'Current week',
202
+ resetsAt,
203
+ resetsIn: resetData?.text || null,
204
+ resetsInSeconds: resetData?.seconds || null,
205
+ };
206
+ }
207
+ const percentMatch = clean.match(/Current\s+week[^%]*?(\d+)\s*%\s*used/i);
208
+ if (percentMatch) {
209
+ return {
210
+ percent: parseFloat(percentMatch[1]),
211
+ label: 'Current week',
212
+ resetsAt: null,
213
+ resetsIn: null,
214
+ resetsInSeconds: null,
215
+ };
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Add pace data to a usage section
222
+ * @param {Object|null} sectionData - Usage section data
223
+ * @param {string} cycleType - Type of cycle ('session' or 'weekly')
224
+ * @returns {Object|null} Section data with pace info added
225
+ */
226
+ function addPaceData(sectionData, cycleType) {
227
+ if (!sectionData || sectionData.resetsInSeconds == null) return sectionData;
228
+ const cycleDuration = CYCLE_DURATIONS[cycleType];
229
+ if (!cycleDuration) return sectionData;
230
+ const paceData = calculatePace({
231
+ usagePercent: sectionData.percent ?? 0,
232
+ resetsInSeconds: sectionData.resetsInSeconds,
233
+ cycleDurationSeconds: cycleDuration,
234
+ });
235
+ if (paceData) sectionData.pace = paceData;
236
+ return sectionData;
237
+ }
238
+
239
+ /**
240
+ * Parse PTY output from /usage command into unified usage schema
241
+ * @param {string} clean - Cleaned (ANSI-stripped) output text
242
+ * @returns {Object} Parsed usage object
243
+ */
244
+ function parsePtyOutput(clean) {
245
+ const usage = { session: null, weeklyAll: null, weeklySonnet: null };
246
+
247
+ // Parse session
248
+ const sessionData = parseUsageSection(extractSection(clean, 'Current\\s+session', 'Current\\s+week'));
249
+ if (sessionData) {
250
+ usage.session = { label: 'Current session', ...sessionData };
251
+ addPaceData(usage.session, 'session');
252
+ }
253
+
254
+ // Parse weekly (all models)
255
+ const weeklyAllData = parseUsageSection(extractSection(clean, 'Current\\s+week\\s*\\(?\\s*all\\s+models\\s*\\)?', 'Current\\s+week\\s*\\(?\\s*Sonnet'));
256
+ if (weeklyAllData) {
257
+ usage.weeklyAll = { label: 'Current week (all models)', ...weeklyAllData };
258
+ addPaceData(usage.weeklyAll, 'weekly');
259
+ }
260
+
261
+ // Parse weekly (Sonnet only)
262
+ const weeklySonnetData = parseUsageSection(extractSection(clean, 'Current\\s+week\\s*\\(?\\s*Sonnet\\s+only\\s*\\)?', 'Extra\\s+usage|esc\\s+to\\s+cancel|Current\\s+week\\s*\\('));
263
+ if (weeklySonnetData) {
264
+ usage.weeklySonnet = { label: 'Current week (Sonnet only)', ...weeklySonnetData };
265
+ addPaceData(usage.weeklySonnet, 'weekly');
266
+ }
267
+
268
+ // Parse extra usage section
269
+ const extraSection = extractSection(clean, 'Extra\\s+usage', 'esc\\s+to\\s+cancel');
270
+ if (extraSection) {
271
+ const extraData = parseExtraUsage(extraSection);
272
+ if (extraData) {
273
+ usage.extraUsage = extraData;
274
+ addPaceData(usage.extraUsage, 'weekly');
275
+ }
276
+ }
277
+
278
+ // Legacy format fallback
279
+ if (!usage.weeklyAll && !usage.weeklySonnet) {
280
+ const legacyWeekly = parseLegacyWeekly(clean);
281
+ if (legacyWeekly) {
282
+ usage.weekly = legacyWeekly;
283
+ addPaceData(usage.weekly, 'weekly');
284
+ }
285
+ }
286
+
287
+ return usage;
288
+ }
289
+
290
+ module.exports = {
291
+ parsePtyOutput,
292
+ parseResetTime,
293
+ formatDuration,
294
+ to24Hour,
295
+ extractSection,
296
+ extractResetTime,
297
+ parseUsageSection,
298
+ parseExtraUsage,
299
+ parseLegacyWeekly,
300
+ addPaceData,
301
+ };
@@ -0,0 +1,246 @@
1
+ const { ActivityMonitor } = require('./activity-monitor.js');
2
+
3
+ /**
4
+ * Manages auto-refresh behavior for AgentTank.
5
+ * Supports three modes: 'none', 'interval', and 'activity'.
6
+ */
7
+ class AutoRefreshManager {
8
+ /**
9
+ * @param {Object} options
10
+ * @param {Object} options.config - Auto-refresh configuration
11
+ * @param {Map} options.agents - Map of agent instances
12
+ * @param {Function} options.onRefreshAll - Callback to refresh all agents
13
+ * @param {Function} options.onRefreshAgent - Callback to refresh a single agent
14
+ */
15
+ constructor(options) {
16
+ this.config = options.config;
17
+ this.agents = options.agents;
18
+ this.onRefreshAll = options.onRefreshAll;
19
+ this.onRefreshAgent = options.onRefreshAgent;
20
+
21
+ this.autoRefreshTimer = null;
22
+ this.agentRefreshTimers = new Map();
23
+ this.lastRefreshedAt = null;
24
+
25
+ // Activity monitor for activity-based polling
26
+ this.activityMonitor = null;
27
+ this.activityRefreshTimer = null;
28
+ this.isActivityRefreshing = false;
29
+ }
30
+
31
+ /**
32
+ * Start auto-refresh based on configured mode.
33
+ */
34
+ start() {
35
+ this.stop();
36
+
37
+ if (!this.config.enabled || this.config.mode === 'none') {
38
+ console.log('Backend auto-refresh: disabled');
39
+ return;
40
+ }
41
+
42
+ if (this.config.mode === 'activity') {
43
+ this._startActivityBasedRefresh();
44
+ return;
45
+ }
46
+
47
+ this._startIntervalBasedRefresh();
48
+ }
49
+
50
+ /**
51
+ * Stop all auto-refresh timers and monitors.
52
+ */
53
+ stop() {
54
+ if (this.autoRefreshTimer) {
55
+ clearInterval(this.autoRefreshTimer);
56
+ this.autoRefreshTimer = null;
57
+ }
58
+ for (const timer of this.agentRefreshTimers.values()) {
59
+ clearInterval(timer);
60
+ }
61
+ this.agentRefreshTimers.clear();
62
+
63
+ if (this.activityRefreshTimer) {
64
+ clearTimeout(this.activityRefreshTimer);
65
+ this.activityRefreshTimer = null;
66
+ }
67
+ if (this.activityMonitor) {
68
+ this.activityMonitor.stop();
69
+ this.activityMonitor = null;
70
+ }
71
+ this.isActivityRefreshing = false;
72
+ }
73
+
74
+ /**
75
+ * Get the last refresh timestamp.
76
+ * @returns {string|null}
77
+ */
78
+ getLastRefreshedAt() {
79
+ return this.lastRefreshedAt;
80
+ }
81
+
82
+ /**
83
+ * Update the last refresh timestamp.
84
+ * @param {string} timestamp
85
+ */
86
+ setLastRefreshedAt(timestamp) {
87
+ this.lastRefreshedAt = timestamp;
88
+ }
89
+
90
+ /**
91
+ * Get activity monitor status.
92
+ * @returns {Object}
93
+ */
94
+ getActivityMonitorStatus() {
95
+ if (this.activityMonitor) {
96
+ return {
97
+ ...this.activityMonitor.getStatus(),
98
+ isActivityRefreshing: this.isActivityRefreshing,
99
+ };
100
+ }
101
+ return {
102
+ isMonitoring: false,
103
+ debounceInterval: this.config.activityDebounce,
104
+ monitoredAgents: [],
105
+ agentCount: 0,
106
+ activityCount: 0,
107
+ lastActivityAt: {},
108
+ hasPendingActivity: false,
109
+ pendingAgents: [],
110
+ isActivityRefreshing: false,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Start activity-based auto-refresh.
116
+ * @private
117
+ */
118
+ _startActivityBasedRefresh() {
119
+ const agentNames = Array.from(this.agents.keys());
120
+ const debounceMs = this.config.activityDebounce;
121
+ const intervalSeconds = this.config.interval;
122
+
123
+ console.log(`Backend auto-refresh: activity mode (debounce: ${debounceMs}ms, cycle interval: ${intervalSeconds}s)`);
124
+
125
+ this.activityMonitor = new ActivityMonitor({
126
+ debounceInterval: debounceMs,
127
+ agents: agentNames,
128
+ onActivity: (event) => {
129
+ this._handleActivityDetected(event);
130
+ },
131
+ });
132
+
133
+ const startResult = this.activityMonitor.start();
134
+
135
+ if (startResult.agentCount === 0) {
136
+ console.log('[ActivityMonitor] No log directories found to monitor, falling back to interval mode');
137
+ this._startIntervalBasedRefresh();
138
+ return;
139
+ }
140
+
141
+ // Ignore short-lived startup churn immediately after the initial fetch/boot.
142
+ this.activityMonitor.suppressAll(Math.max(this.config.activityDebounce, 5000));
143
+
144
+ console.log(`[ActivityMonitor] Monitoring ${startResult.monitoredDirs.length} directories`);
145
+ }
146
+
147
+ /**
148
+ * Handle activity detection from the ActivityMonitor.
149
+ * @param {Object} event - Activity event from ActivityMonitor
150
+ * @private
151
+ */
152
+ _handleActivityDetected(event) {
153
+ console.log(`[Activity] Detected for ${event.agent}, starting refresh cycle...`);
154
+
155
+ if (this.isActivityRefreshing) {
156
+ console.log('[Activity] Already in refresh cycle, activity will extend it');
157
+ return;
158
+ }
159
+
160
+ this.isActivityRefreshing = true;
161
+ this._runActivityRefreshCycle();
162
+ }
163
+
164
+ /**
165
+ * Run a single iteration of the activity refresh cycle.
166
+ * @private
167
+ */
168
+ async _runActivityRefreshCycle() {
169
+ try {
170
+ if (this.activityMonitor) {
171
+ // Ignore short-lived filesystem churn caused by our own refresh commands.
172
+ this.activityMonitor.suppressAll(Math.max(this.config.activityDebounce, 5000));
173
+ }
174
+ console.log('[Activity-refresh] Running refresh cycle...');
175
+ await this.onRefreshAll();
176
+ this.lastRefreshedAt = new Date().toISOString();
177
+ } catch (err) {
178
+ console.error('[Activity-refresh] Error during refresh:', err.message);
179
+ }
180
+
181
+ const hasPending = this.activityMonitor && this.activityMonitor.hasPendingActivity();
182
+
183
+ if (hasPending) {
184
+ const intervalMs = this.config.interval * 1000;
185
+ console.log(`[Activity-refresh] More activity pending, scheduling next cycle in ${this.config.interval}s`);
186
+
187
+ this.activityRefreshTimer = setTimeout(() => {
188
+ this._runActivityRefreshCycle();
189
+ }, intervalMs);
190
+ } else {
191
+ console.log('[Activity-refresh] No more pending activity, going idle');
192
+ this.isActivityRefreshing = false;
193
+ this.activityRefreshTimer = null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Start interval-based auto-refresh (traditional polling).
199
+ * @private
200
+ */
201
+ _startIntervalBasedRefresh() {
202
+ const globalInterval = this.config.interval;
203
+
204
+ if (globalInterval <= 0) {
205
+ console.log('Backend auto-refresh: disabled (interval is 0)');
206
+ return;
207
+ }
208
+
209
+ const globalAgents = [];
210
+ for (const [name, agent] of this.agents) {
211
+ const effective = agent.refreshInterval
212
+ ?? (agent.minRefreshInterval ? Math.max(globalInterval, agent.minRefreshInterval) : null);
213
+ if (effective && effective > globalInterval) {
214
+ const intervalMs = effective * 1000;
215
+ console.log(`Backend auto-refresh [${name}]: every ${effective}s`);
216
+ const timer = setInterval(async () => {
217
+ console.log(`[Auto-refresh] Refreshing ${name}...`);
218
+ await this.onRefreshAgent(name).catch(err =>
219
+ console.error(`Error refreshing ${name}:`, err.message)
220
+ );
221
+ this.lastRefreshedAt = new Date().toISOString();
222
+ }, intervalMs);
223
+ this.agentRefreshTimers.set(name, timer);
224
+ } else {
225
+ globalAgents.push(name);
226
+ }
227
+ }
228
+
229
+ if (globalAgents.length > 0) {
230
+ console.log(`Backend auto-refresh [${globalAgents.join(', ')}]: every ${globalInterval}s (interval mode)`);
231
+ this.autoRefreshTimer = setInterval(async () => {
232
+ console.log(`[Auto-refresh] Refreshing ${globalAgents.join(', ')}...`);
233
+ const promises = globalAgents.map(name => {
234
+ const agent = this.agents.get(name);
235
+ return agent ? agent.refresh().catch(err =>
236
+ console.error(`Error refreshing ${name}:`, err.message)
237
+ ) : Promise.resolve();
238
+ });
239
+ await Promise.all(promises);
240
+ this.lastRefreshedAt = new Date().toISOString();
241
+ }, globalInterval * 1000);
242
+ }
243
+ }
244
+ }
245
+
246
+ module.exports = { AutoRefreshManager };
@@ -0,0 +1,143 @@
1
+ // Client-side JavaScript helpers for auto-refresh system
2
+ // These helper functions are injected into the client-side code
3
+
4
+ const colorHelpers = `
5
+ // Color helper functions (mirrored from server-side usage-formatters.js)
6
+ function getColorClassFromPercent(value) {
7
+ if (value < 50) return 'high';
8
+ if (value < 80) return 'medium';
9
+ return 'low';
10
+ }
11
+
12
+ function getStatusDotClassFromPercent(value) {
13
+ if (value <= 50) return 'status-green';
14
+ if (value <= 80) return 'status-yellow';
15
+ return 'status-red';
16
+ }
17
+
18
+ function getProgressColorFromPercent(value) {
19
+ if (value < 50) return '#48bb78';
20
+ if (value < 80) return '#ecc94b';
21
+ return '#e53e3e';
22
+ }
23
+ `;
24
+
25
+ const timeHelpers = `
26
+ // Parse "Resets in" time string to seconds (mirrored from server-side)
27
+ function parseResetsInToSeconds(resetsIn) {
28
+ if (!resetsIn || typeof resetsIn !== 'string') return null;
29
+ let totalSeconds = 0;
30
+ const dayMatch = resetsIn.match(/(\\d+)\\s*d(?:ay)?s?/i);
31
+ const hourMatch = resetsIn.match(/(\\d+)\\s*h(?:our)?s?/i);
32
+ const minMatch = resetsIn.match(/(\\d+)\\s*m(?:in(?:ute)?)?s?/i);
33
+ const secMatch = resetsIn.match(/(\\d+)\\s*s(?:ec(?:ond)?)?s?/i);
34
+ if (dayMatch) totalSeconds += parseInt(dayMatch[1], 10) * 24 * 60 * 60;
35
+ if (hourMatch) totalSeconds += parseInt(hourMatch[1], 10) * 60 * 60;
36
+ if (minMatch) totalSeconds += parseInt(minMatch[1], 10) * 60;
37
+ if (secMatch) totalSeconds += parseInt(secMatch[1], 10);
38
+ return totalSeconds > 0 ? totalSeconds : null;
39
+ }
40
+
41
+ // Cycle duration constants (mirrored from server-side)
42
+ const CYCLE_DURATIONS = {
43
+ session: 5 * 60 * 60,
44
+ sessionGemini: 24 * 60 * 60,
45
+ weekly: 7 * 24 * 60 * 60,
46
+ fiveHour: 5 * 60 * 60
47
+ };
48
+ `;
49
+
50
+ const metricExtractors = `
51
+ // Extract metrics from Claude usage data
52
+ function extractClaudeMetrics(usage) {
53
+ const metrics = [];
54
+ const sections = [
55
+ { data: usage.session, label: 'Session', cycle: 'session' },
56
+ { data: usage.weeklyAll, label: 'Weekly (all)', cycle: 'weekly' },
57
+ { data: usage.weeklySonnet, label: 'Weekly (Sonnet)', cycle: 'weekly' },
58
+ { data: usage.weekly, label: 'Weekly', cycle: 'weekly' }
59
+ ];
60
+ for (const { data, label, cycle } of sections) {
61
+ if (data) {
62
+ const metricId = \`claude-\${label.toLowerCase().replace(/[^a-z0-9]/g, '-')}\`;
63
+ metrics.push({
64
+ metricId,
65
+ agent: 'claude',
66
+ label,
67
+ percent: data.percent ?? 0,
68
+ resetsIn: data.resetsIn || '',
69
+ resetsAt: data.resetsAt || '',
70
+ cycle
71
+ });
72
+ }
73
+ }
74
+ return metrics;
75
+ }
76
+
77
+ // Extract metrics from Gemini usage data
78
+ function extractGeminiMetrics(usage) {
79
+ const metrics = [];
80
+ if (usage.models && usage.models.length > 0) {
81
+ for (const model of usage.models) {
82
+ const modelName = model.model.toLowerCase();
83
+ const metricId = \`gemini-\${modelName.replace(/[^a-z0-9]/g, '-')}\`;
84
+ metrics.push({
85
+ metricId,
86
+ agent: 'gemini',
87
+ label: modelName,
88
+ percent: model.percentUsed ?? 0,
89
+ resetsIn: model.resetsIn || '',
90
+ resetsAt: model.resetsAt || '',
91
+ cycle: 'sessionGemini'
92
+ });
93
+ }
94
+ }
95
+ return metrics;
96
+ }
97
+
98
+ // Extract metrics from Codex usage data
99
+ function extractCodexMetrics(usage) {
100
+ const metrics = [];
101
+ const models = [];
102
+ if (usage.fiveHour || usage.weekly) {
103
+ models.push({ name: usage.model || 'Default', fiveHour: usage.fiveHour, weekly: usage.weekly });
104
+ }
105
+ if (usage.modelLimits) {
106
+ models.push(...usage.modelLimits);
107
+ }
108
+ for (const ml of models) {
109
+ const modelSlug = ml.name.toLowerCase().replace(/[^a-z0-9]/g, '-');
110
+ if (ml.fiveHour) {
111
+ metrics.push({
112
+ metricId: \`codex-\${modelSlug}-5h\`,
113
+ agent: 'codex',
114
+ label: '5h limit',
115
+ modelName: ml.name,
116
+ percent: ml.fiveHour.percentUsed ?? 0,
117
+ resetsIn: ml.fiveHour.resetsIn || '',
118
+ resetsAt: ml.fiveHour.resetsAt || '',
119
+ cycle: 'fiveHour'
120
+ });
121
+ }
122
+ if (ml.weekly) {
123
+ metrics.push({
124
+ metricId: \`codex-\${modelSlug}-weekly\`,
125
+ agent: 'codex',
126
+ label: 'Weekly',
127
+ modelName: ml.name,
128
+ percent: ml.weekly.percentUsed ?? 0,
129
+ resetsIn: ml.weekly.resetsIn || '',
130
+ resetsAt: ml.weekly.resetsAt || '',
131
+ cycle: 'weekly'
132
+ });
133
+ }
134
+ }
135
+ return metrics;
136
+ }
137
+ `;
138
+
139
+ module.exports = {
140
+ colorHelpers,
141
+ timeHelpers,
142
+ metricExtractors
143
+ };