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/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 };