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
package/src/index.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const { ClaudeAgent } = require('./agents/claude.js');
|
|
3
|
+
const { GeminiAgent } = require('./agents/gemini.js');
|
|
4
|
+
const { CodexAgent } = require('./agents/codex.js');
|
|
5
|
+
const { discoverAgents } = require('./discovery.js');
|
|
6
|
+
const { fetchPublicStatus } = require('./public-status.js');
|
|
7
|
+
const { createServer, displayServerBanner } = require('./server.js');
|
|
8
|
+
const logger = require('./logger.js');
|
|
9
|
+
const { HistoryStore, DEFAULT_RETENTION_DAYS } = require('./history-store.js');
|
|
10
|
+
const { extractSnapshotMetrics } = require('./snapshot-metrics.js');
|
|
11
|
+
const { attachPaceEvaluation } = require('./pace-attachment.js');
|
|
12
|
+
const { KeepaliveManager } = require('./keepalive-manager.js');
|
|
13
|
+
const { AutoRefreshManager } = require('./auto-refresh-manager.js');
|
|
14
|
+
|
|
15
|
+
// Load package.json for version info
|
|
16
|
+
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Valid auto-refresh modes:
|
|
20
|
+
* - 'none': No automatic refresh (manual refresh only)
|
|
21
|
+
* - 'interval': Traditional interval-based polling (refreshes at fixed intervals)
|
|
22
|
+
* - 'activity': Activity-based polling (refreshes when log activity is detected)
|
|
23
|
+
*/
|
|
24
|
+
const AUTO_REFRESH_MODES = ['none', 'interval', 'activity'];
|
|
25
|
+
|
|
26
|
+
class AgentTank {
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.port = options.port || 3456;
|
|
29
|
+
this.host = options.host || '127.0.0.1';
|
|
30
|
+
this.autoDiscover = options.autoDiscover !== false;
|
|
31
|
+
this.requestedAgents = options.agents || null;
|
|
32
|
+
this.freshProcess = options.freshProcess || false;
|
|
33
|
+
this.claudeApi = options.claudeApi || false; // Use direct Anthropic API for Claude
|
|
34
|
+
this.auth = options.auth || {};
|
|
35
|
+
this.agents = new Map();
|
|
36
|
+
this.server = null;
|
|
37
|
+
this.publicStatus = {}; // Public API status from upstream providers
|
|
38
|
+
this.skipServer = options.skipServer || false; // Skip HTTP server in one-shot mode
|
|
39
|
+
this.lastRefreshedAt = null;
|
|
40
|
+
|
|
41
|
+
// Auto-refresh configuration (backend periodic refresh)
|
|
42
|
+
// Determine the mode: 'none', 'interval', or 'activity' (default: 'activity')
|
|
43
|
+
const rawMode = options.autoRefreshMode ?? 'activity';
|
|
44
|
+
const autoRefreshMode = AUTO_REFRESH_MODES.includes(rawMode) ? rawMode : 'activity';
|
|
45
|
+
|
|
46
|
+
// Handle backwards compatibility: if autoRefreshEnabled is explicitly false, use 'none' mode
|
|
47
|
+
if (options.autoRefreshEnabled === false) {
|
|
48
|
+
this.autoRefresh = {
|
|
49
|
+
mode: 'none',
|
|
50
|
+
enabled: false,
|
|
51
|
+
interval: options.autoRefreshInterval ?? 60,
|
|
52
|
+
activityDebounce: options.activityDebounce ?? 5000, // Default: 5 seconds in ms
|
|
53
|
+
};
|
|
54
|
+
} else {
|
|
55
|
+
this.autoRefresh = {
|
|
56
|
+
mode: autoRefreshMode,
|
|
57
|
+
enabled: autoRefreshMode !== 'none',
|
|
58
|
+
interval: options.autoRefreshInterval ?? 60, // Default: 60 seconds
|
|
59
|
+
activityDebounce: options.activityDebounce ?? 5000, // Default: 5 seconds in ms
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Auto-refresh manager (initialized in start())
|
|
64
|
+
this.autoRefreshManager = null;
|
|
65
|
+
|
|
66
|
+
// History store for usage snapshots
|
|
67
|
+
this.historyRetentionDays = options.historyRetentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
68
|
+
this.historyStore = new HistoryStore({
|
|
69
|
+
retentionDays: this.historyRetentionDays
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Keepalive manager for session maintenance
|
|
73
|
+
this.keepalive = {
|
|
74
|
+
enabled: options.keepaliveEnabled !== false, // Default: true
|
|
75
|
+
interval: options.keepaliveInterval ?? 300, // Default: 5 minutes (300 seconds)
|
|
76
|
+
};
|
|
77
|
+
this.keepaliveManager = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Display startup banner with website URL, version, author copyright,
|
|
82
|
+
* and agent refresh logic description
|
|
83
|
+
*/
|
|
84
|
+
displayStartupBanner() {
|
|
85
|
+
const ANSI = logger.ANSI;
|
|
86
|
+
const version = pkg.version || '1.0.0';
|
|
87
|
+
const homepage = pkg.homepage || 'https://github.com/integry/agent-tank';
|
|
88
|
+
const author = pkg.author || 'Rinalds Uzkalns';
|
|
89
|
+
const year = new Date().getFullYear();
|
|
90
|
+
|
|
91
|
+
console.log('');
|
|
92
|
+
// ASCII art logo matching the brand: tank/container icon with fluid level lines
|
|
93
|
+
// Using box-drawing characters with rounded corners and battery emoji for visual appeal
|
|
94
|
+
// ASCII art text for "AGENT TANK" using block letters - properly aligned
|
|
95
|
+
console.log(`${ANSI.brightCyan} ╭───╮${ANSI.reset}`);
|
|
96
|
+
console.log(`${ANSI.brightCyan} ╭─┴───┴─╮${ANSI.reset} ${ANSI.brightWhite}█████╗ ██████╗ ███████╗███╗ ██╗████████╗${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold}████████╗ █████╗ ███╗ ██╗██╗ ██╗${ANSI.reset}`);
|
|
97
|
+
console.log(`${ANSI.brightCyan} │ ━━━━━ │${ANSI.reset} ${ANSI.brightWhite}██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold}╚══██╔══╝██╔══██╗████╗ ██║██║ ██╔╝${ANSI.reset}`);
|
|
98
|
+
console.log(`${ANSI.brightCyan} │ ━━━━━ │${ANSI.reset} ${ANSI.brightWhite}███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold} ██║ ███████║██╔██╗ ██║█████╔╝ ${ANSI.reset}`);
|
|
99
|
+
console.log(`${ANSI.brightCyan} │ ━━━━━ │${ANSI.reset} ${ANSI.brightWhite}██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold} ██║ ██╔══██║██║╚██╗██║██╔═██╗ ${ANSI.reset}`);
|
|
100
|
+
console.log(`${ANSI.brightCyan} │ ━━━━━ │${ANSI.reset} ${ANSI.brightWhite}██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold} ██║ ██║ ██║██║ ╚████║██║ ██╗${ANSI.reset}`);
|
|
101
|
+
console.log(`${ANSI.brightCyan} ╰───────╯${ANSI.reset} ${ANSI.brightWhite}╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ${ANSI.reset} ${ANSI.brightCyan}${ANSI.bold} ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝${ANSI.reset}`);
|
|
102
|
+
console.log('');
|
|
103
|
+
console.log(`${ANSI.dim} Monitor your AI agent usage${ANSI.reset}`);
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(`${ANSI.dim}Version ${version}${ANSI.reset}`);
|
|
106
|
+
console.log(`${ANSI.brightWhite}${ANSI.underline}${homepage}${ANSI.reset}`);
|
|
107
|
+
console.log(`${ANSI.dim}© ${year} ${author}${ANSI.reset}`);
|
|
108
|
+
console.log('');
|
|
109
|
+
|
|
110
|
+
// Display agent refresh logic description
|
|
111
|
+
console.log(`${ANSI.cyan}${ANSI.bold}Agent Refresh Logic:${ANSI.reset}`);
|
|
112
|
+
if (!this.autoRefresh.enabled || this.autoRefresh.interval <= 0) {
|
|
113
|
+
console.log(`${ANSI.dim} • Auto-refresh: disabled${ANSI.reset}`);
|
|
114
|
+
} else {
|
|
115
|
+
console.log(`${ANSI.dim} • Auto-refresh: enabled (default interval: ${this.autoRefresh.interval}s)${ANSI.reset}`);
|
|
116
|
+
console.log(`${ANSI.dim} • Agents are polled periodically via PTY sessions to fetch usage data${ANSI.reset}`);
|
|
117
|
+
console.log(`${ANSI.dim} • Agents with higher minimum intervals use their own refresh timers${ANSI.reset}`);
|
|
118
|
+
}
|
|
119
|
+
console.log(`${ANSI.dim} • Manual refresh available via POST /refresh or POST /refresh/:agent${ANSI.reset}`);
|
|
120
|
+
console.log('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async start() {
|
|
124
|
+
// Display startup banner with website URL, version, and copyright
|
|
125
|
+
this.displayStartupBanner();
|
|
126
|
+
|
|
127
|
+
// Initialize agents
|
|
128
|
+
let agentNames = this.requestedAgents;
|
|
129
|
+
|
|
130
|
+
if (!agentNames || agentNames.length === 0) {
|
|
131
|
+
if (this.autoDiscover) {
|
|
132
|
+
logger.info('🔍 Auto-discovering available LLM agents...');
|
|
133
|
+
agentNames = await discoverAgents();
|
|
134
|
+
if (agentNames.length === 0) {
|
|
135
|
+
logger.warn('No compatible LLM agents found.');
|
|
136
|
+
logger.info('Requirements:');
|
|
137
|
+
logger.info(' - Claude Code 2.0+ (for /usage command support)');
|
|
138
|
+
logger.info(' - Gemini CLI 0.24.5+ (for /stats command support)');
|
|
139
|
+
logger.info(' - Codex CLI');
|
|
140
|
+
logger.info('\nInstall/update with:');
|
|
141
|
+
logger.info(' npm install -g @anthropic-ai/claude-code@latest');
|
|
142
|
+
logger.info(' npm install -g gemini@latest');
|
|
143
|
+
throw new Error('No agents found');
|
|
144
|
+
}
|
|
145
|
+
logger.success(`✅ Found agents: ${agentNames.join(', ')}`);
|
|
146
|
+
} else {
|
|
147
|
+
throw new Error('No agents specified and auto-discover disabled.');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Create agent instances
|
|
152
|
+
for (const name of agentNames) {
|
|
153
|
+
const agent = this.createAgent(name);
|
|
154
|
+
if (agent) {
|
|
155
|
+
agent.freshProcess = this.freshProcess;
|
|
156
|
+
this.agents.set(name, agent);
|
|
157
|
+
logger.agent(name, `Created agent${this.freshProcess ? ' (fresh process mode)' : ''}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Start HTTP server immediately so it's available during agent loading (unless skipped)
|
|
162
|
+
if (!this.skipServer) {
|
|
163
|
+
this.startServer();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Pre-spawn persistent processes in parallel before sending commands
|
|
167
|
+
if (!this.freshProcess) {
|
|
168
|
+
logger.info('🚀 Spawning agent processes...');
|
|
169
|
+
await Promise.all(
|
|
170
|
+
Array.from(this.agents.values()).map(agent =>
|
|
171
|
+
agent.spawnProcess().catch(err =>
|
|
172
|
+
logger.error(`Error spawning ${agent.name}:`, err.message)
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Initial fetch
|
|
179
|
+
logger.info('📊 Fetching initial usage data...');
|
|
180
|
+
await this.refreshAll();
|
|
181
|
+
|
|
182
|
+
// Start backend auto-refresh if enabled (skip in one-shot mode)
|
|
183
|
+
if (!this.skipServer) {
|
|
184
|
+
this.startAutoRefresh();
|
|
185
|
+
this.startKeepalive();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Start the keepalive manager to maintain agent sessions. */
|
|
190
|
+
startKeepalive() {
|
|
191
|
+
this.stopKeepalive();
|
|
192
|
+
if (!this.keepalive.enabled || this.keepalive.interval <= 0) { console.log('Session keepalive: disabled'); return; }
|
|
193
|
+
if (this.freshProcess) { console.log('Session keepalive: disabled (fresh process mode)'); return; }
|
|
194
|
+
this.keepaliveManager = new KeepaliveManager({ enabled: this.keepalive.enabled, interval: this.keepalive.interval });
|
|
195
|
+
for (const [name, agent] of this.agents) { this.keepaliveManager.register(name, agent); }
|
|
196
|
+
this.keepaliveManager.start(); console.log(`Session keepalive: every ${this.keepalive.interval}s`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stop the keepalive manager. */
|
|
200
|
+
stopKeepalive() { if (this.keepaliveManager) { this.keepaliveManager.stop(); this.keepaliveManager = null; } }
|
|
201
|
+
|
|
202
|
+
startAutoRefresh() {
|
|
203
|
+
this.autoRefreshManager = new AutoRefreshManager({
|
|
204
|
+
config: this.autoRefresh,
|
|
205
|
+
agents: this.agents,
|
|
206
|
+
onRefreshAll: () => this.refreshAll(),
|
|
207
|
+
onRefreshAgent: (name) => this.refreshAgent(name),
|
|
208
|
+
});
|
|
209
|
+
this.autoRefreshManager.start();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
stopAutoRefresh() {
|
|
213
|
+
if (this.autoRefreshManager) {
|
|
214
|
+
this.autoRefreshManager.stop();
|
|
215
|
+
this.autoRefreshManager = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
createAgent(name) {
|
|
220
|
+
switch (name) {
|
|
221
|
+
case 'claude':
|
|
222
|
+
return new ClaudeAgent({ useApi: this.claudeApi });
|
|
223
|
+
case 'gemini':
|
|
224
|
+
return new GeminiAgent();
|
|
225
|
+
case 'codex':
|
|
226
|
+
return new CodexAgent();
|
|
227
|
+
default:
|
|
228
|
+
logger.warn(`Unknown agent: ${name}`);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async refreshAll() {
|
|
234
|
+
logger.info('🔄 Refreshing all agents...');
|
|
235
|
+
const agentNames = Array.from(this.agents.keys());
|
|
236
|
+
|
|
237
|
+
// Fetch PTY agent data and public status concurrently
|
|
238
|
+
const [, publicStatusResult] = await Promise.all([
|
|
239
|
+
// Agent PTY refreshes
|
|
240
|
+
Promise.all(
|
|
241
|
+
Array.from(this.agents.values()).map(agent =>
|
|
242
|
+
agent.refresh().catch(err => {
|
|
243
|
+
logger.error(`Error refreshing ${agent.name}:`, err.message);
|
|
244
|
+
})
|
|
245
|
+
)
|
|
246
|
+
),
|
|
247
|
+
// Public API status polling
|
|
248
|
+
fetchPublicStatus(agentNames).catch(err => {
|
|
249
|
+
logger.error('Error fetching public status:', err.message);
|
|
250
|
+
return {};
|
|
251
|
+
}),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
this.publicStatus = publicStatusResult;
|
|
255
|
+
this.lastRefreshedAt = new Date().toISOString();
|
|
256
|
+
|
|
257
|
+
// Record usage snapshots and attach pace evaluations
|
|
258
|
+
for (const [name, agent] of this.agents) {
|
|
259
|
+
if (agent.usage) {
|
|
260
|
+
this._recordSnapshot(name, agent.usage);
|
|
261
|
+
attachPaceEvaluation(name, agent.usage);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
logger.success('✅ All agents refresh complete');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Record a usage snapshot to the history store. @private */
|
|
269
|
+
_recordSnapshot(agentName, usage) {
|
|
270
|
+
try {
|
|
271
|
+
const snapshot = extractSnapshotMetrics(agentName, usage);
|
|
272
|
+
if (snapshot) this.historyStore.addSnapshot(agentName, snapshot);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.error(`[${agentName}] Error recording snapshot:`, err.message);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async refreshAgent(name) {
|
|
279
|
+
const agent = this.agents.get(name);
|
|
280
|
+
if (!agent) {
|
|
281
|
+
throw new Error(`Agent not found: ${name}`);
|
|
282
|
+
}
|
|
283
|
+
await agent.refresh();
|
|
284
|
+
|
|
285
|
+
// Record snapshot and attach pace evaluation for single agent refresh
|
|
286
|
+
if (agent.usage) {
|
|
287
|
+
this._recordSnapshot(name, agent.usage);
|
|
288
|
+
attachPaceEvaluation(name, agent.usage);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Get usage history statistics. @returns {Object} */
|
|
293
|
+
getHistoryStats() { return this.historyStore.getStats(); }
|
|
294
|
+
|
|
295
|
+
/** Get usage history for an agent. @param {string} [agentName] @returns {Array} */
|
|
296
|
+
getHistory(agentName = null) { return this.historyStore.getHistory(agentName); }
|
|
297
|
+
|
|
298
|
+
getStatus() {
|
|
299
|
+
const status = {};
|
|
300
|
+
for (const [name, agent] of this.agents) {
|
|
301
|
+
status[name] = { ...agent.getStatus(), publicStatus: this.publicStatus[name] || null };
|
|
302
|
+
}
|
|
303
|
+
return status;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
getAgentStatus(name) {
|
|
307
|
+
const agent = this.agents.get(name);
|
|
308
|
+
return agent ? agent.getStatus() : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
authenticate(req, url) {
|
|
312
|
+
const hasBasicAuth = this.auth.user && this.auth.pass;
|
|
313
|
+
const hasTokenAuth = !!this.auth.token;
|
|
314
|
+
|
|
315
|
+
// No auth configured — allow all
|
|
316
|
+
if (!hasBasicAuth && !hasTokenAuth) return true;
|
|
317
|
+
|
|
318
|
+
const authHeader = req.headers['authorization'] || '';
|
|
319
|
+
|
|
320
|
+
// Check Bearer token
|
|
321
|
+
if (hasTokenAuth && authHeader.startsWith('Bearer ')) {
|
|
322
|
+
if (authHeader.slice(7) === this.auth.token) return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Check query param token
|
|
326
|
+
if (hasTokenAuth && url.searchParams.get('token') === this.auth.token) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check Basic auth
|
|
331
|
+
if (hasBasicAuth && authHeader.startsWith('Basic ')) {
|
|
332
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
333
|
+
const [user, pass] = decoded.split(':');
|
|
334
|
+
if (user === this.auth.user && pass === this.auth.pass) return true;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
startServer() {
|
|
341
|
+
this.server = createServer(this);
|
|
342
|
+
this.server.listen(this.port, this.host, () => {
|
|
343
|
+
displayServerBanner(this);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
stop() {
|
|
348
|
+
this.stopAutoRefresh();
|
|
349
|
+
this.stopKeepalive();
|
|
350
|
+
for (const agent of this.agents.values()) {
|
|
351
|
+
agent.killProcess();
|
|
352
|
+
}
|
|
353
|
+
if (this.server) {
|
|
354
|
+
this.server.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Get keepalive status. @returns {Object|null} Keepalive manager status or null if not initialized */
|
|
359
|
+
getKeepaliveStatus() {
|
|
360
|
+
if (this.keepaliveManager) return this.keepaliveManager.getStatus();
|
|
361
|
+
return { enabled: this.keepalive.enabled, interval: this.keepalive.interval, isRunning: false, registeredAgents: [], lastKeepaliveAt: null };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Get activity monitor status. @returns {Object} */
|
|
365
|
+
getActivityMonitorStatus() {
|
|
366
|
+
if (this.autoRefreshManager) return this.autoRefreshManager.getActivityMonitorStatus();
|
|
367
|
+
return { isMonitoring: false, debounceInterval: this.autoRefresh.activityDebounce, monitoredAgents: [], agentCount: 0, activityCount: 0, lastActivityAt: {}, hasPendingActivity: false, pendingAgents: [], isActivityRefreshing: false };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Get the auto-refresh configuration. @returns {Object} */
|
|
371
|
+
getAutoRefreshConfig() {
|
|
372
|
+
return { mode: this.autoRefresh.mode, enabled: this.autoRefresh.enabled, interval: this.autoRefresh.interval, activityDebounce: this.autoRefresh.activityDebounce, lastRefreshedAt: this.autoRefreshManager ? this.autoRefreshManager.getLastRefreshedAt() : null };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = { AgentTank, AUTO_REFRESH_MODES };
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonRpcClient - A utility for JSON-RPC 2.0 subprocess communication
|
|
3
|
+
*
|
|
4
|
+
* This client spawns a subprocess and communicates via JSON-RPC 2.0 protocol
|
|
5
|
+
* over stdin/stdout. It handles message framing and asynchronous responses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const { EventEmitter } = require('events');
|
|
10
|
+
|
|
11
|
+
class JsonRpcClient extends EventEmitter {
|
|
12
|
+
/**
|
|
13
|
+
* Create a new JsonRpcClient
|
|
14
|
+
* @param {string} command - The command to spawn
|
|
15
|
+
* @param {string[]} args - Arguments for the command
|
|
16
|
+
* @param {Object} options - Options for the subprocess
|
|
17
|
+
*/
|
|
18
|
+
constructor(command, args = [], options = {}) {
|
|
19
|
+
super();
|
|
20
|
+
this.command = command;
|
|
21
|
+
this.args = args;
|
|
22
|
+
this.options = {
|
|
23
|
+
cwd: options.cwd || process.cwd(),
|
|
24
|
+
env: options.env || process.env,
|
|
25
|
+
timeout: options.timeout || 30000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
this.process = null;
|
|
29
|
+
this.requestId = 0;
|
|
30
|
+
this.pendingRequests = new Map();
|
|
31
|
+
this.buffer = '';
|
|
32
|
+
this.isConnected = false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start the subprocess and establish JSON-RPC communication
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async start() {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
try {
|
|
42
|
+
this.process = spawn(this.command, this.args, {
|
|
43
|
+
cwd: this.options.cwd,
|
|
44
|
+
env: this.options.env,
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.process.on('error', (err) => {
|
|
49
|
+
this.isConnected = false;
|
|
50
|
+
this.emit('error', err);
|
|
51
|
+
if (!this.isConnected) {
|
|
52
|
+
reject(new Error(`Failed to spawn ${this.command}: ${err.message}`));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.process.on('exit', (code, signal) => {
|
|
57
|
+
this.isConnected = false;
|
|
58
|
+
this.emit('exit', { code, signal });
|
|
59
|
+
|
|
60
|
+
// Reject all pending requests
|
|
61
|
+
for (const [id, { reject: rejectFn }] of this.pendingRequests.entries()) {
|
|
62
|
+
rejectFn(new Error(`Process exited with code ${code} while waiting for response`));
|
|
63
|
+
this.pendingRequests.delete(id);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.process.stdout.setEncoding('utf8');
|
|
68
|
+
this.process.stdout.on('data', (data) => {
|
|
69
|
+
this._handleData(data);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.process.stderr.setEncoding('utf8');
|
|
73
|
+
this.process.stderr.on('data', (data) => {
|
|
74
|
+
this.emit('stderr', data);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Consider connected after process starts successfully
|
|
78
|
+
// Give it a moment to initialize
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
if (this.process && !this.process.killed) {
|
|
81
|
+
this.isConnected = true;
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
}, 100);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
reject(new Error(`Failed to spawn ${this.command}: ${err.message}`));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Handle incoming data from stdout
|
|
93
|
+
* @param {string} data - Raw data from stdout
|
|
94
|
+
* @private
|
|
95
|
+
*/
|
|
96
|
+
_handleData(data) {
|
|
97
|
+
this.buffer += data;
|
|
98
|
+
|
|
99
|
+
// Process complete JSON-RPC messages
|
|
100
|
+
// Messages may be line-delimited or concatenated JSON objects
|
|
101
|
+
this._processBuffer();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Process the buffer for complete JSON-RPC messages
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
_processBuffer() {
|
|
109
|
+
// Try to parse line-delimited JSON
|
|
110
|
+
const lines = this.buffer.split('\n');
|
|
111
|
+
|
|
112
|
+
// Keep the last incomplete line in the buffer
|
|
113
|
+
this.buffer = lines.pop() || '';
|
|
114
|
+
|
|
115
|
+
for (const line of lines) {
|
|
116
|
+
const trimmed = line.trim();
|
|
117
|
+
if (!trimmed) continue;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const message = JSON.parse(trimmed);
|
|
121
|
+
this._handleMessage(message);
|
|
122
|
+
} catch {
|
|
123
|
+
// Not valid JSON, might be a partial message or non-JSON output
|
|
124
|
+
// Try to extract JSON from the line
|
|
125
|
+
const jsonMatch = trimmed.match(/\{.*\}/);
|
|
126
|
+
if (jsonMatch) {
|
|
127
|
+
try {
|
|
128
|
+
const message = JSON.parse(jsonMatch[0]);
|
|
129
|
+
this._handleMessage(message);
|
|
130
|
+
} catch {
|
|
131
|
+
// Emit as raw output for debugging
|
|
132
|
+
this.emit('raw', trimmed);
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
this.emit('raw', trimmed);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Handle a parsed JSON-RPC message
|
|
143
|
+
* @param {Object} message - Parsed JSON-RPC message
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
_handleMessage(message) {
|
|
147
|
+
// Check if it's a response (has id and result/error)
|
|
148
|
+
if (message.id !== undefined && message.id !== null) {
|
|
149
|
+
const pending = this.pendingRequests.get(message.id);
|
|
150
|
+
if (pending) {
|
|
151
|
+
clearTimeout(pending.timer);
|
|
152
|
+
this.pendingRequests.delete(message.id);
|
|
153
|
+
|
|
154
|
+
if (message.error) {
|
|
155
|
+
pending.reject(new JsonRpcError(message.error.code, message.error.message, message.error.data));
|
|
156
|
+
} else {
|
|
157
|
+
pending.resolve(message.result);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check if it's a notification (has method but no id)
|
|
164
|
+
if (message.method && message.id === undefined) {
|
|
165
|
+
this.emit('notification', message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Emit as unknown message
|
|
170
|
+
this.emit('message', message);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Send a JSON-RPC request and wait for response
|
|
175
|
+
* @param {string} method - The RPC method to call
|
|
176
|
+
* @param {Object} params - Parameters for the method
|
|
177
|
+
* @param {number} timeout - Timeout in milliseconds (optional)
|
|
178
|
+
* @returns {Promise<any>} - The result from the RPC call
|
|
179
|
+
*/
|
|
180
|
+
async call(method, params = {}, timeout = null) {
|
|
181
|
+
if (!this.isConnected || !this.process) {
|
|
182
|
+
throw new Error('Client is not connected');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const id = ++this.requestId;
|
|
186
|
+
const request = {
|
|
187
|
+
jsonrpc: '2.0',
|
|
188
|
+
id,
|
|
189
|
+
method,
|
|
190
|
+
params,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const timeoutMs = timeout || this.options.timeout;
|
|
195
|
+
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
this.pendingRequests.delete(id);
|
|
198
|
+
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
|
|
201
|
+
this.pendingRequests.set(id, { resolve, reject, timer });
|
|
202
|
+
|
|
203
|
+
// Send the request
|
|
204
|
+
const message = JSON.stringify(request) + '\n';
|
|
205
|
+
try {
|
|
206
|
+
this.process.stdin.write(message);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
this.pendingRequests.delete(id);
|
|
210
|
+
reject(new Error(`Failed to send request: ${err.message}`));
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Send a JSON-RPC notification (no response expected)
|
|
217
|
+
* @param {string} method - The RPC method to call
|
|
218
|
+
* @param {Object} params - Parameters for the method
|
|
219
|
+
*/
|
|
220
|
+
notify(method, params = {}) {
|
|
221
|
+
if (!this.isConnected || !this.process) {
|
|
222
|
+
throw new Error('Client is not connected');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const notification = {
|
|
226
|
+
jsonrpc: '2.0',
|
|
227
|
+
method,
|
|
228
|
+
params,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const message = JSON.stringify(notification) + '\n';
|
|
232
|
+
this.process.stdin.write(message);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stop the subprocess
|
|
237
|
+
*/
|
|
238
|
+
stop() {
|
|
239
|
+
if (this.process) {
|
|
240
|
+
// Clear all pending requests
|
|
241
|
+
for (const [id, { reject, timer }] of this.pendingRequests.entries()) {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
reject(new Error('Client stopped'));
|
|
244
|
+
this.pendingRequests.delete(id);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
this.process.stdin.end();
|
|
249
|
+
} catch {
|
|
250
|
+
// Ignore errors during cleanup
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
this.process.kill();
|
|
255
|
+
} catch {
|
|
256
|
+
// Ignore errors during cleanup
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.process = null;
|
|
260
|
+
this.isConnected = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Check if the client is connected
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
get connected() {
|
|
269
|
+
return this.isConnected && this.process && !this.process.killed;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Custom error class for JSON-RPC errors
|
|
275
|
+
*/
|
|
276
|
+
class JsonRpcError extends Error {
|
|
277
|
+
constructor(code, message, data = null) {
|
|
278
|
+
super(message);
|
|
279
|
+
this.name = 'JsonRpcError';
|
|
280
|
+
this.code = code;
|
|
281
|
+
this.data = data;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { JsonRpcClient, JsonRpcError };
|