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,269 @@
1
+ /**
2
+ * Pace Evaluator Module
3
+ *
4
+ * Calculates whether usage is out-pacing the time window.
5
+ * Warns users if they are burning through rate limits faster than time is elapsing.
6
+ * Provides extrapolation of ETA until 100% usage is reached.
7
+ */
8
+
9
+ /**
10
+ * Calculate the pace of usage relative to time elapsed in a cycle.
11
+ *
12
+ * @param {Object} options - The pace calculation options
13
+ * @param {number} options.usagePercent - Current usage percentage (0-100)
14
+ * @param {number} options.resetsInSeconds - Seconds until the cycle resets
15
+ * @param {number} options.cycleDurationSeconds - Total duration of the cycle in seconds
16
+ * @param {number} [options.warningThreshold=1.2] - Pace ratio threshold to trigger warning (default: 20% faster than time)
17
+ * @returns {Object|null} Pace data or null if calculation not possible
18
+ * - paceRatio: Usage rate vs time rate (>1 means out-pacing)
19
+ * - elapsedPercent: Percentage of time elapsed in the cycle
20
+ * - isWarning: Whether usage is significantly out-pacing time
21
+ * - warningMessage: Human-readable warning message (if warning triggered)
22
+ */
23
+ function calculatePace(options) {
24
+ const {
25
+ usagePercent,
26
+ resetsInSeconds,
27
+ cycleDurationSeconds,
28
+ warningThreshold = 1.2
29
+ } = options;
30
+
31
+ // Validate inputs
32
+ if (
33
+ typeof usagePercent !== 'number' ||
34
+ typeof resetsInSeconds !== 'number' ||
35
+ typeof cycleDurationSeconds !== 'number' ||
36
+ cycleDurationSeconds <= 0
37
+ ) {
38
+ return null;
39
+ }
40
+
41
+ // Calculate elapsed time in the cycle
42
+ const elapsedSeconds = cycleDurationSeconds - resetsInSeconds;
43
+
44
+ // Handle edge case: no time elapsed yet
45
+ if (elapsedSeconds <= 0) {
46
+ return {
47
+ paceRatio: usagePercent > 0 ? Infinity : 0,
48
+ elapsedPercent: 0,
49
+ isWarning: usagePercent > 0,
50
+ warningMessage: usagePercent > 0 ? 'Usage started before time window opened' : null
51
+ };
52
+ }
53
+
54
+ const elapsedPercent = (elapsedSeconds / cycleDurationSeconds) * 100;
55
+
56
+ // Handle edge case: no usage
57
+ if (usagePercent === 0) {
58
+ return {
59
+ paceRatio: 0,
60
+ elapsedPercent,
61
+ isWarning: false,
62
+ warningMessage: null
63
+ };
64
+ }
65
+
66
+ // Calculate pace ratio: usage rate vs time rate
67
+ // paceRatio > 1 means using faster than time is passing
68
+ // paceRatio = 2 means using at 2x the sustainable rate
69
+ const paceRatio = usagePercent / elapsedPercent;
70
+
71
+ // Determine if warning should be triggered
72
+ const isWarning = paceRatio >= warningThreshold;
73
+
74
+ // Generate warning message if applicable
75
+ let warningMessage = null;
76
+ if (isWarning) {
77
+ const paceMultiplier = paceRatio.toFixed(1);
78
+ warningMessage = `Using ${paceMultiplier}x faster than sustainable`;
79
+ }
80
+
81
+ return {
82
+ paceRatio: Math.round(paceRatio * 100) / 100, // Round to 2 decimal places
83
+ elapsedPercent: Math.round(elapsedPercent * 100) / 100,
84
+ isWarning,
85
+ warningMessage
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Evaluate usage pace and extrapolate when user will hit 100%.
91
+ *
92
+ * This function calculates the expected linear usage based on elapsed time,
93
+ * determines if the user is burning faster than expected, and if so,
94
+ * extrapolates the ETA in seconds until they hit 100%.
95
+ *
96
+ * @param {Object} options - The evaluation options
97
+ * @param {number} options.usagePercent - Current usage percentage (0-100)
98
+ * @param {number} options.resetsInSeconds - Seconds until the cycle resets
99
+ * @param {number} options.cycleDurationSeconds - Total duration of the cycle in seconds
100
+ * @param {number} [options.warningThreshold=1.2] - Pace ratio threshold to trigger warning
101
+ * @returns {Object|null} Pace evaluation result or null if calculation not possible
102
+ * - expectedPercent: Expected usage percentage based on linear pace
103
+ * - isBurningFast: Whether actual usage exceeds expected usage
104
+ * - etaSeconds: Seconds until 100% usage (only if isBurningFast is true, null otherwise)
105
+ * - paceRatio: Usage rate vs time rate (>1 means out-pacing)
106
+ * - elapsedPercent: Percentage of time elapsed in the cycle
107
+ * - deltaPercent: Difference between actual and expected usage (positive = ahead)
108
+ */
109
+ function evaluatePace(options) {
110
+ const {
111
+ usagePercent,
112
+ resetsInSeconds,
113
+ cycleDurationSeconds,
114
+ warningThreshold = 1.2
115
+ } = options;
116
+
117
+ // Validate inputs
118
+ if (
119
+ typeof usagePercent !== 'number' ||
120
+ typeof resetsInSeconds !== 'number' ||
121
+ typeof cycleDurationSeconds !== 'number' ||
122
+ cycleDurationSeconds <= 0
123
+ ) {
124
+ return null;
125
+ }
126
+
127
+ // Calculate elapsed time in the cycle
128
+ const elapsedSeconds = cycleDurationSeconds - resetsInSeconds;
129
+
130
+ // Handle edge case: no time elapsed yet
131
+ if (elapsedSeconds <= 0) {
132
+ return {
133
+ expectedPercent: 0,
134
+ isBurningFast: usagePercent > 0,
135
+ etaSeconds: usagePercent > 0 ? 0 : null, // Already burning if any usage at start
136
+ paceRatio: usagePercent > 0 ? Infinity : 0,
137
+ elapsedPercent: 0,
138
+ deltaPercent: usagePercent
139
+ };
140
+ }
141
+
142
+ // Calculate expected usage based on linear pace
143
+ // If 50% of time has elapsed, expected usage should be 50%
144
+ const elapsedPercent = (elapsedSeconds / cycleDurationSeconds) * 100;
145
+ const expectedPercent = elapsedPercent;
146
+
147
+ // Calculate delta (positive = ahead of pace, negative = behind)
148
+ const deltaPercent = usagePercent - expectedPercent;
149
+
150
+ // Determine if burning fast (usage exceeds expected by threshold)
151
+ // Using threshold ratio: if usage/expected >= threshold, burning fast
152
+ const paceRatio = usagePercent > 0 && elapsedPercent > 0
153
+ ? usagePercent / elapsedPercent
154
+ : (usagePercent > 0 ? Infinity : 0);
155
+
156
+ const isBurningFast = paceRatio >= warningThreshold;
157
+
158
+ // Calculate ETA to 100% if burning fast
159
+ let etaSeconds = null;
160
+ if (isBurningFast && usagePercent < 100 && usagePercent > 0) {
161
+ // Current burn rate: usagePercent per elapsedSeconds
162
+ const burnRatePerSecond = usagePercent / elapsedSeconds;
163
+
164
+ // Remaining usage to reach 100%
165
+ const remainingPercent = 100 - usagePercent;
166
+
167
+ // Time to reach 100% at current burn rate
168
+ etaSeconds = Math.round(remainingPercent / burnRatePerSecond);
169
+ } else if (usagePercent >= 100) {
170
+ etaSeconds = 0; // Already at or past 100%
171
+ }
172
+
173
+ return {
174
+ expectedPercent: Math.round(expectedPercent * 100) / 100,
175
+ isBurningFast,
176
+ etaSeconds,
177
+ paceRatio: paceRatio === Infinity ? Infinity : Math.round(paceRatio * 100) / 100,
178
+ elapsedPercent: Math.round(elapsedPercent * 100) / 100,
179
+ deltaPercent: Math.round(deltaPercent * 100) / 100
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Format a pace warning as HTML for display in the UI.
185
+ * Includes ETA countdown when burning fast.
186
+ *
187
+ * @param {Object|null} paceData - Result from evaluatePace() or calculatePace()
188
+ * @returns {string} HTML string for the pace warning (empty string if no warning)
189
+ */
190
+ function formatPaceWarning(paceData) {
191
+ if (!paceData) {
192
+ return '';
193
+ }
194
+
195
+ // Determine if we should show warning based on isWarning or isBurningFast
196
+ const shouldShowWarning = paceData.isWarning || paceData.isBurningFast;
197
+ if (!shouldShowWarning) {
198
+ return '';
199
+ }
200
+
201
+ const paceDisplay = paceData.paceRatio.toFixed(1);
202
+ // Add critical class for 2x or higher pace
203
+ const criticalClass = paceData.paceRatio >= 2 ? ' pace-critical' : '';
204
+
205
+ // Build tooltip message
206
+ let tooltip = paceData.warningMessage || `Using ${paceDisplay}x faster than sustainable`;
207
+
208
+ // Generate ETA section if available
209
+ let etaHtml = '';
210
+ if (paceData.etaSeconds != null && paceData.etaSeconds >= 0 && paceData.isBurningFast) {
211
+ const etaString = formatEtaString(paceData.etaSeconds);
212
+ if (etaString) {
213
+ etaHtml = `<span class="pace-eta">runs out in ${etaString}</span>`;
214
+ tooltip += ` • Runs out in ${etaString}`;
215
+ }
216
+ }
217
+
218
+ return `<div class="pace-warning${criticalClass}" title="${tooltip}">
219
+ <span class="pace-icon">&#9888;</span>
220
+ <span class="pace-text">${paceDisplay}x pace</span>${etaHtml}
221
+ </div>`;
222
+ }
223
+
224
+ /**
225
+ * Format ETA seconds into a human-readable string.
226
+ *
227
+ * @param {number|null} etaSeconds - Seconds until 100% usage
228
+ * @returns {string} Formatted ETA string (e.g., "2h 15m", "45m", "~30s")
229
+ */
230
+ function formatEtaString(etaSeconds) {
231
+ if (etaSeconds == null || etaSeconds < 0) {
232
+ return '';
233
+ }
234
+
235
+ if (etaSeconds === 0) {
236
+ return 'now';
237
+ }
238
+
239
+ const days = Math.floor(etaSeconds / (24 * 60 * 60));
240
+ const hours = Math.floor((etaSeconds % (24 * 60 * 60)) / (60 * 60));
241
+ const minutes = Math.floor((etaSeconds % (60 * 60)) / 60);
242
+
243
+ const parts = [];
244
+
245
+ if (days > 0) {
246
+ parts.push(`${days}d`);
247
+ }
248
+ if (hours > 0) {
249
+ parts.push(`${hours}h`);
250
+ }
251
+ if (minutes > 0 && days === 0) {
252
+ // Only show minutes if no days (for readability)
253
+ parts.push(`${minutes}m`);
254
+ }
255
+
256
+ // If less than a minute, show "< 1m"
257
+ if (parts.length === 0) {
258
+ return '< 1m';
259
+ }
260
+
261
+ return parts.join(' ');
262
+ }
263
+
264
+ module.exports = {
265
+ calculatePace,
266
+ formatPaceWarning,
267
+ evaluatePace,
268
+ formatEtaString
269
+ };
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Public Status API Polling Module
3
+ *
4
+ * Fetches status information from public API status pages for Anthropic, OpenAI,
5
+ * and Google Cloud. Uses Statuspage.io JSON API for Anthropic and OpenAI, and
6
+ * Google Cloud's incidents JSON for Google/Gemini.
7
+ */
8
+
9
+ const https = require('node:https');
10
+
11
+ // Status page configurations
12
+ const STATUS_PAGES = {
13
+ claude: {
14
+ url: 'https://anthropic.statuspage.io/api/v2/status.json',
15
+ type: 'statuspage',
16
+ name: 'Anthropic',
17
+ },
18
+ codex: {
19
+ url: 'https://status.openai.com/api/v2/status.json',
20
+ type: 'statuspage',
21
+ name: 'OpenAI',
22
+ },
23
+ gemini: {
24
+ url: 'https://status.cloud.google.com/incidents.json',
25
+ type: 'google-cloud',
26
+ name: 'Google Cloud',
27
+ // Vertex AI Gemini product ID for filtering incidents
28
+ productId: 'Z0FZJAMvEB4j3NbCJs6B',
29
+ },
30
+ };
31
+
32
+ // Status indicator mapping (statuspage.io format)
33
+ // Possible values: none, minor, major, critical
34
+ const STATUS_INDICATOR_MAP = {
35
+ none: 'operational',
36
+ minor: 'degraded',
37
+ major: 'outage',
38
+ critical: 'outage',
39
+ };
40
+
41
+ // Request timeout in milliseconds
42
+ const FETCH_TIMEOUT = 10000;
43
+
44
+ /**
45
+ * Makes an HTTPS GET request and returns the response body as a string
46
+ * @param {string} url - The URL to fetch
47
+ * @returns {Promise<string>} - The response body
48
+ */
49
+ function httpsGet(url) {
50
+ return new Promise((resolve, reject) => {
51
+ const request = https.get(url, { timeout: FETCH_TIMEOUT }, (res) => {
52
+ if (res.statusCode !== 200) {
53
+ reject(new Error(`HTTP ${res.statusCode}`));
54
+ return;
55
+ }
56
+
57
+ let data = '';
58
+ res.on('data', (chunk) => {
59
+ data += chunk;
60
+ });
61
+ res.on('end', () => {
62
+ resolve(data);
63
+ });
64
+ });
65
+
66
+ request.on('error', reject);
67
+ request.on('timeout', () => {
68
+ request.destroy();
69
+ reject(new Error('Request timeout'));
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Parse Statuspage.io JSON response
76
+ * @param {string} json - Raw JSON response
77
+ * @returns {object} - Parsed status object
78
+ */
79
+ function parseStatuspageResponse(json) {
80
+ const data = JSON.parse(json);
81
+ const indicator = data.status?.indicator || 'none';
82
+ const description = data.status?.description || 'Unknown';
83
+
84
+ return {
85
+ status: STATUS_INDICATOR_MAP[indicator] || 'unknown',
86
+ indicator,
87
+ description,
88
+ updatedAt: data.page?.updated_at || null,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Parse Google Cloud incidents JSON response
94
+ * Determines status based on recent active incidents affecting the specified product
95
+ * @param {string} json - Raw JSON response
96
+ * @param {string} productId - Product ID to filter incidents
97
+ * @returns {object} - Parsed status object
98
+ */
99
+ function parseGoogleCloudResponse(json, productId) {
100
+ const incidents = JSON.parse(json);
101
+
102
+ // Find active incidents affecting the specified product (e.g., Vertex AI Gemini)
103
+ const now = new Date();
104
+ const activeIncidents = incidents.filter((incident) => {
105
+ // Check if incident is ongoing (no end date or recent)
106
+ if (incident.end) {
107
+ const endDate = new Date(incident.end);
108
+ // Consider incidents that ended less than 1 hour ago as potentially still affecting
109
+ if (now - endDate > 60 * 60 * 1000) {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ // Check if incident affects the target product
115
+ if (productId && incident.affected_products) {
116
+ const affectsProduct = incident.affected_products.some(
117
+ (p) => p.id === productId || p.title?.toLowerCase().includes('gemini') ||
118
+ p.title?.toLowerCase().includes('vertex')
119
+ );
120
+ if (!affectsProduct) {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ // Check status - include ongoing or recent disruptions
126
+ return incident.status === 'SERVICE_DISRUPTION' ||
127
+ incident.status === 'SERVICE_OUTAGE' ||
128
+ !incident.end;
129
+ });
130
+
131
+ // Determine overall status based on active incidents
132
+ if (activeIncidents.length === 0) {
133
+ return {
134
+ status: 'operational',
135
+ indicator: 'none',
136
+ description: 'All Systems Operational',
137
+ updatedAt: null,
138
+ };
139
+ }
140
+
141
+ // Find the most severe active incident
142
+ const hasOutage = activeIncidents.some((i) => i.status === 'SERVICE_OUTAGE');
143
+ const hasDisruption = activeIncidents.some((i) => i.status === 'SERVICE_DISRUPTION');
144
+
145
+ if (hasOutage) {
146
+ return {
147
+ status: 'outage',
148
+ indicator: 'major',
149
+ description: activeIncidents[0].external_desc || 'Service Outage',
150
+ updatedAt: activeIncidents[0].modified || null,
151
+ };
152
+ }
153
+
154
+ if (hasDisruption) {
155
+ return {
156
+ status: 'degraded',
157
+ indicator: 'minor',
158
+ description: activeIncidents[0].external_desc || 'Service Disruption',
159
+ updatedAt: activeIncidents[0].modified || null,
160
+ };
161
+ }
162
+
163
+ // Default to operational if incidents are resolved
164
+ return {
165
+ status: 'operational',
166
+ indicator: 'none',
167
+ description: 'All Systems Operational',
168
+ updatedAt: null,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Fetch public status for a single agent
174
+ * @param {string} agentName - Name of the agent (claude, gemini, codex)
175
+ * @returns {Promise<object>} - Status object with status, indicator, description
176
+ */
177
+ async function fetchAgentPublicStatus(agentName) {
178
+ const config = STATUS_PAGES[agentName];
179
+
180
+ if (!config) {
181
+ return {
182
+ status: 'unknown',
183
+ indicator: 'unknown',
184
+ description: 'No status page configured',
185
+ error: null,
186
+ };
187
+ }
188
+
189
+ try {
190
+ const response = await httpsGet(config.url);
191
+
192
+ if (config.type === 'statuspage') {
193
+ return parseStatuspageResponse(response);
194
+ } else if (config.type === 'google-cloud') {
195
+ return parseGoogleCloudResponse(response, config.productId);
196
+ }
197
+
198
+ return {
199
+ status: 'unknown',
200
+ indicator: 'unknown',
201
+ description: 'Unknown status page type',
202
+ error: null,
203
+ };
204
+ } catch (err) {
205
+ console.error(`[PublicStatus] Error fetching ${agentName} status:`, err.message);
206
+ return {
207
+ status: 'unknown',
208
+ indicator: 'unknown',
209
+ description: 'Unable to fetch status',
210
+ error: err.message,
211
+ };
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Fetch public status for all configured agents
217
+ * @param {string[]} agentNames - Array of agent names to fetch status for
218
+ * @returns {Promise<object>} - Object mapping agent names to their public status
219
+ */
220
+ async function fetchPublicStatus(agentNames = Object.keys(STATUS_PAGES)) {
221
+ const results = {};
222
+
223
+ const promises = agentNames.map(async (name) => {
224
+ results[name] = await fetchAgentPublicStatus(name);
225
+ });
226
+
227
+ await Promise.all(promises);
228
+
229
+ return results;
230
+ }
231
+
232
+ /**
233
+ * Get the badge color class for a status
234
+ * @param {string} status - Status string (operational, degraded, outage, unknown)
235
+ * @returns {string} - CSS class name for the badge
236
+ */
237
+ function getStatusBadgeClass(status) {
238
+ switch (status) {
239
+ case 'operational':
240
+ return 'status-badge-green';
241
+ case 'degraded':
242
+ return 'status-badge-yellow';
243
+ case 'outage':
244
+ return 'status-badge-red';
245
+ default:
246
+ return 'status-badge-grey';
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Get the human-readable status text
252
+ * @param {string} status - Status string
253
+ * @returns {string} - Human-readable status
254
+ */
255
+ function getStatusText(status) {
256
+ switch (status) {
257
+ case 'operational':
258
+ return 'Operational';
259
+ case 'degraded':
260
+ return 'Degraded';
261
+ case 'outage':
262
+ return 'Outage';
263
+ default:
264
+ return 'Unknown';
265
+ }
266
+ }
267
+
268
+ module.exports = {
269
+ fetchPublicStatus,
270
+ fetchAgentPublicStatus,
271
+ getStatusBadgeClass,
272
+ getStatusText,
273
+ parseStatuspageResponse,
274
+ parseGoogleCloudResponse,
275
+ STATUS_PAGES,
276
+ };
package/src/server.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * HTTP Server module for Agent Tank
3
+ * Handles routing and request processing
4
+ */
5
+
6
+ const http = require('node:http');
7
+ const logger = require('./logger.js');
8
+ const { statusPage } = require('./status-page.js');
9
+
10
+ function createRequestHandler(tank) {
11
+ return async (req, res) => {
12
+ const url = new URL(req.url, `http://localhost:${tank.port}`);
13
+ const path = url.pathname;
14
+
15
+ // CORS headers
16
+ res.setHeader('Access-Control-Allow-Origin', '*');
17
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
18
+
19
+ if (req.method === 'OPTIONS') {
20
+ res.writeHead(204);
21
+ res.end();
22
+ return;
23
+ }
24
+
25
+ if (!tank.authenticate(req, url)) {
26
+ res.setHeader('WWW-Authenticate', 'Basic realm="LLM Limit Watcher"');
27
+ res.writeHead(401);
28
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
29
+ return;
30
+ }
31
+
32
+ try {
33
+ // Routes
34
+ if (req.method === 'GET' && path === '/') {
35
+ res.setHeader('Content-Type', 'text/html');
36
+ res.writeHead(200);
37
+ res.end(statusPage(tank.getStatus()));
38
+ return;
39
+ }
40
+
41
+ if (req.method === 'GET' && path === '/status') {
42
+ const status = tank.getStatus();
43
+ logger.server(`GET /status - returning status for ${Object.keys(status).length} agents`);
44
+ res.setHeader('Content-Type', 'application/json');
45
+ res.writeHead(200);
46
+ res.end(JSON.stringify(status, null, 2));
47
+ return;
48
+ }
49
+
50
+ if (req.method === 'GET' && path === '/config') {
51
+ const config = {
52
+ autoRefresh: {
53
+ enabled: tank.autoRefresh.enabled && tank.autoRefresh.interval > 0,
54
+ interval: tank.autoRefresh.interval, // in seconds
55
+ },
56
+ lastRefreshedAt: tank.lastRefreshedAt,
57
+ };
58
+ res.setHeader('Content-Type', 'application/json');
59
+ res.writeHead(200);
60
+ res.end(JSON.stringify(config, null, 2));
61
+ return;
62
+ }
63
+
64
+ if (req.method === 'GET' && path.startsWith('/status/')) {
65
+ const agentName = path.slice(8);
66
+ const status = tank.getAgentStatus(agentName);
67
+ if (!status) {
68
+ res.writeHead(404);
69
+ res.end(JSON.stringify({ error: 'Agent not found' }));
70
+ return;
71
+ }
72
+ res.setHeader('Content-Type', 'application/json');
73
+ res.writeHead(200);
74
+ res.end(JSON.stringify(status, null, 2));
75
+ return;
76
+ }
77
+
78
+ if (req.method === 'POST' && path === '/refresh') {
79
+ await tank.refreshAll();
80
+ res.setHeader('Content-Type', 'application/json');
81
+ res.writeHead(200);
82
+ res.end(JSON.stringify({ success: true, status: tank.getStatus() }));
83
+ return;
84
+ }
85
+
86
+ if (req.method === 'POST' && path.startsWith('/refresh/')) {
87
+ const agentName = path.slice(9);
88
+ try {
89
+ await tank.refreshAgent(agentName);
90
+ res.setHeader('Content-Type', 'application/json');
91
+ res.writeHead(200);
92
+ res.end(JSON.stringify({ success: true, status: tank.getAgentStatus(agentName) }));
93
+ } catch (err) {
94
+ res.writeHead(404);
95
+ res.end(JSON.stringify({ error: err.message }));
96
+ }
97
+ return;
98
+ }
99
+
100
+ // 404
101
+ res.writeHead(404);
102
+ res.end(JSON.stringify({ error: 'Not found' }));
103
+
104
+ } catch (err) {
105
+ logger.error('Server error:', err);
106
+ res.writeHead(500);
107
+ res.end(JSON.stringify({ error: 'Internal server error' }));
108
+ }
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Create and configure the HTTP server
114
+ * @param {Object} tank - AgentTank instance
115
+ * @returns {http.Server}
116
+ */
117
+ function createServer(tank) {
118
+ return http.createServer(createRequestHandler(tank));
119
+ }
120
+
121
+ /**
122
+ * Display server startup banner
123
+ * @param {Object} tank - AgentTank instance
124
+ */
125
+ function displayServerBanner(tank) {
126
+ const ANSI = logger.ANSI;
127
+ console.log('');
128
+ console.log(`${ANSI.cyan}${ANSI.bold}╔════════════════════════════════════════════╗${ANSI.reset}`);
129
+ console.log(`${ANSI.cyan}${ANSI.bold}║${ANSI.reset} ${ANSI.brightCyan}🚀 Agent Tank Server${ANSI.reset} ${ANSI.cyan}${ANSI.bold}║${ANSI.reset}`);
130
+ console.log(`${ANSI.cyan}${ANSI.bold}╚════════════════════════════════════════════╝${ANSI.reset}`);
131
+ console.log('');
132
+ if (tank.auth.user && tank.auth.pass) {
133
+ logger.info(`🔐 Authentication: basic auth (user: ${tank.auth.user})`);
134
+ }
135
+ if (tank.auth.token) {
136
+ logger.info('🔑 Authentication: API key');
137
+ }
138
+ logger.server(`Listening on: ${tank.host}:${tank.port}`);
139
+ logger.server(`📄 Status page: http://${tank.host}:${tank.port}/`);
140
+ logger.server(`📡 JSON API: http://${tank.host}:${tank.port}/status`);
141
+ console.log('');
142
+ }
143
+
144
+ module.exports = { createRequestHandler, createServer, displayServerBanner };