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