@learnrudi/cli 1.10.5 → 1.10.7

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "1.0.0",
3
- "generated": "2026-01-10T22:47:26.415Z",
3
+ "generated": "2026-01-11T14:26:24.225Z",
4
4
  "packages": {
5
5
  "runtimes": [
6
6
  {
@@ -32,6 +32,16 @@ const TOOL_INDEX_PATH = path.join(RUDI_HOME, 'cache', 'tool-index.json');
32
32
 
33
33
  const REQUEST_TIMEOUT_MS = 30000;
34
34
  const PROTOCOL_VERSION = '2024-11-05';
35
+ const DEFAULT_IDLE_TTL_MS = 10 * 60 * 1000;
36
+ const DEFAULT_MAX_SERVERS = 8;
37
+ const DEFAULT_CLEANUP_INTERVAL_MS = 30000;
38
+ const DEFAULT_FORCE_KILL_MS = 2000;
39
+
40
+ const IDLE_TTL_MS = readIntEnv('RUDI_ROUTER_IDLE_TTL_MS', DEFAULT_IDLE_TTL_MS);
41
+ const MAX_SERVERS = readIntEnv('RUDI_ROUTER_MAX_SERVERS', DEFAULT_MAX_SERVERS);
42
+ const CLEANUP_INTERVAL_MS = readIntEnv('RUDI_ROUTER_CLEANUP_INTERVAL_MS', DEFAULT_CLEANUP_INTERVAL_MS);
43
+ const FORCE_KILL_MS = readIntEnv('RUDI_ROUTER_FORCE_KILL_MS', DEFAULT_FORCE_KILL_MS);
44
+ const LIVE_TOOL_LIST = readBoolEnv('RUDI_ROUTER_LIVE_TOOL_LIST', false);
35
45
 
36
46
  // =============================================================================
37
47
  // STATE
@@ -45,6 +55,7 @@ let rudiConfig = null;
45
55
 
46
56
  /** @type {Object | null} */
47
57
  let toolIndex = null;
58
+ let cleanupTimer = null;
48
59
 
49
60
  // =============================================================================
50
61
  // TYPES (JSDoc)
@@ -57,6 +68,10 @@ let toolIndex = null;
57
68
  * @property {Map<string|number, PendingRequest>} pending
58
69
  * @property {string} buffer
59
70
  * @property {boolean} initialized
71
+ * @property {string} stackId
72
+ * @property {number} spawnedAt
73
+ * @property {number} lastUsedAt
74
+ * @property {boolean} terminating
60
75
  */
61
76
 
62
77
  /**
@@ -96,6 +111,102 @@ function debug(msg) {
96
111
  }
97
112
  }
98
113
 
114
+ // =============================================================================
115
+ // ENV & PROCESS HELPERS
116
+ // =============================================================================
117
+
118
+ function readIntEnv(name, fallback) {
119
+ const raw = process.env[name];
120
+ if (raw === undefined) return fallback;
121
+ const value = Number(raw);
122
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
123
+ }
124
+
125
+ function readBoolEnv(name, fallback) {
126
+ const raw = process.env[name];
127
+ if (raw === undefined) return fallback;
128
+ return ['1', 'true', 'yes', 'on'].includes(String(raw).toLowerCase());
129
+ }
130
+
131
+ function hasProcessExited(proc) {
132
+ return proc.exitCode !== null || proc.signalCode !== null;
133
+ }
134
+
135
+ function isProcessUsable(proc) {
136
+ return proc && !hasProcessExited(proc) && !proc.killed;
137
+ }
138
+
139
+ function markServerUsed(server) {
140
+ server.lastUsedAt = Date.now();
141
+ }
142
+
143
+ function rejectPending(server, reason) {
144
+ for (const [, pending] of server.pending) {
145
+ clearTimeout(pending.timeout);
146
+ pending.reject(new Error(reason));
147
+ }
148
+ server.pending.clear();
149
+ }
150
+
151
+ function terminateServer(stackId, server, reason) {
152
+ if (!server || server.terminating) return;
153
+
154
+ server.terminating = true;
155
+ serverPool.delete(stackId);
156
+
157
+ if (hasProcessExited(server.process)) {
158
+ rejectPending(server, `Stack ${stackId} exited`);
159
+ return;
160
+ }
161
+
162
+ log(`Stopping stack ${stackId}: ${reason}`);
163
+ try {
164
+ server.process.kill('SIGTERM');
165
+ } catch {
166
+ // Ignore kill errors; process may already be gone.
167
+ }
168
+
169
+ const killTimer = setTimeout(() => {
170
+ if (!hasProcessExited(server.process)) {
171
+ log(`Force killing stack ${stackId}`);
172
+ try {
173
+ server.process.kill('SIGKILL');
174
+ } catch {
175
+ // Ignore kill errors; process may already be gone.
176
+ }
177
+ }
178
+ }, FORCE_KILL_MS);
179
+ if (killTimer.unref) killTimer.unref();
180
+ }
181
+
182
+ function cleanupServerPool() {
183
+ const now = Date.now();
184
+
185
+ for (const [stackId, server] of serverPool) {
186
+ if (server.terminating) continue;
187
+ if (!isProcessUsable(server.process)) {
188
+ terminateServer(stackId, server, 'process-not-usable');
189
+ continue;
190
+ }
191
+ if (server.pending.size > 0) continue;
192
+ if (IDLE_TTL_MS > 0 && now - server.lastUsedAt > IDLE_TTL_MS) {
193
+ terminateServer(stackId, server, `idle ${Math.round((now - server.lastUsedAt) / 1000)}s`);
194
+ }
195
+ }
196
+
197
+ if (MAX_SERVERS > 0 && serverPool.size > MAX_SERVERS) {
198
+ const evictable = Array.from(serverPool.entries())
199
+ .filter(([, server]) => !server.terminating && server.pending.size === 0)
200
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
201
+
202
+ let index = 0;
203
+ while (serverPool.size > MAX_SERVERS && index < evictable.length) {
204
+ const [stackId, server] = evictable[index++];
205
+ terminateServer(stackId, server, 'pool-limit');
206
+ }
207
+ }
208
+ }
209
+
99
210
  // =============================================================================
100
211
  // CONFIG & SECRETS
101
212
  // =============================================================================
@@ -208,7 +319,11 @@ function spawnStackServer(stackId, stackConfig) {
208
319
  rl,
209
320
  pending: new Map(),
210
321
  buffer: '',
211
- initialized: false
322
+ initialized: false,
323
+ stackId,
324
+ spawnedAt: Date.now(),
325
+ lastUsedAt: Date.now(),
326
+ terminating: false
212
327
  };
213
328
 
214
329
  // Handle responses from stack
@@ -227,6 +342,7 @@ function spawnStackServer(stackId, stackConfig) {
227
342
  if (pending) {
228
343
  clearTimeout(pending.timeout);
229
344
  server.pending.delete(response.id);
345
+ markServerUsed(server);
230
346
  pending.resolve(response);
231
347
  }
232
348
  } catch (err) {
@@ -241,11 +357,13 @@ function spawnStackServer(stackId, stackConfig) {
241
357
 
242
358
  childProcess.on('error', (err) => {
243
359
  log(`Stack process error (${stackId}): ${err.message}`);
360
+ rejectPending(server, `Stack ${stackId} error: ${err.message}`);
244
361
  serverPool.delete(stackId);
245
362
  });
246
363
 
247
364
  childProcess.on('exit', (code, signal) => {
248
365
  debug(`Stack ${stackId} exited: code=${code}, signal=${signal}`);
366
+ rejectPending(server, `Stack ${stackId} exited (code=${code}, signal=${signal || 'none'})`);
249
367
  rl.close();
250
368
  serverPool.delete(stackId);
251
369
  });
@@ -260,9 +378,13 @@ function spawnStackServer(stackId, stackConfig) {
260
378
  */
261
379
  function getOrSpawnServer(stackId) {
262
380
  const existing = serverPool.get(stackId);
263
- if (existing && !existing.process.killed) {
381
+ if (existing && isProcessUsable(existing.process) && !existing.terminating) {
382
+ markServerUsed(existing);
264
383
  return existing;
265
384
  }
385
+ if (existing) {
386
+ terminateServer(stackId, existing, 'stale');
387
+ }
266
388
 
267
389
  const stackConfig = rudiConfig?.stacks?.[stackId];
268
390
  if (!stackConfig) {
@@ -287,6 +409,11 @@ function getOrSpawnServer(stackId) {
287
409
  */
288
410
  async function sendToStack(server, request, timeoutMs = REQUEST_TIMEOUT_MS) {
289
411
  return new Promise((resolve, reject) => {
412
+ if (!isProcessUsable(server.process) || server.terminating) {
413
+ reject(new Error(`Stack ${server.stackId} is not available`));
414
+ return;
415
+ }
416
+
290
417
  const timeout = setTimeout(() => {
291
418
  server.pending.delete(request.id);
292
419
  reject(new Error(`Request timeout: ${request.method}`));
@@ -296,6 +423,7 @@ async function sendToStack(server, request, timeoutMs = REQUEST_TIMEOUT_MS) {
296
423
 
297
424
  const line = JSON.stringify(request) + '\n';
298
425
  debug(`>> ${line.slice(0, 200)}`);
426
+ markServerUsed(server);
299
427
  server.process.stdin?.write(line);
300
428
  });
301
429
  }
@@ -307,6 +435,7 @@ async function sendToStack(server, request, timeoutMs = REQUEST_TIMEOUT_MS) {
307
435
  */
308
436
  async function initializeStack(server, stackId) {
309
437
  if (server.initialized) return;
438
+ markServerUsed(server);
310
439
 
311
440
  const initRequest = {
312
441
  jsonrpc: '2.0',
@@ -350,6 +479,7 @@ async function initializeStack(server, stackId) {
350
479
  */
351
480
  async function listTools() {
352
481
  const tools = [];
482
+ const skippedStacks = [];
353
483
 
354
484
  for (const [stackId, stackConfig] of Object.entries(rudiConfig?.stacks || {})) {
355
485
  if (!stackConfig.installed) continue;
@@ -376,6 +506,10 @@ async function listTools() {
376
506
  }
377
507
 
378
508
  // 3. Fall back to querying the stack (slow, spawns server)
509
+ if (!LIVE_TOOL_LIST) {
510
+ skippedStacks.push(stackId);
511
+ continue;
512
+ }
379
513
  try {
380
514
  const server = getOrSpawnServer(stackId);
381
515
  await initializeStack(server, stackId);
@@ -399,6 +533,10 @@ async function listTools() {
399
533
  }
400
534
  }
401
535
 
536
+ if (skippedStacks.length > 0) {
537
+ log(`Skipped live tools/list for ${skippedStacks.length} stacks (enable RUDI_ROUTER_LIVE_TOOL_LIST=1 or run "rudi index")`);
538
+ }
539
+
402
540
  return tools;
403
541
  }
404
542
 
@@ -521,6 +659,8 @@ async function handleRequest(request) {
521
659
  */
522
660
  async function main() {
523
661
  log('Starting RUDI Router MCP Server');
662
+ log(`Pool config: max=${MAX_SERVERS <= 0 ? 'unlimited' : MAX_SERVERS}, idleTTL=${IDLE_TTL_MS}ms, cleanup=${CLEANUP_INTERVAL_MS}ms`);
663
+ log(`Live tools/list: ${LIVE_TOOL_LIST ? 'enabled' : 'disabled'}`);
524
664
 
525
665
  // Load config
526
666
  rudiConfig = loadRudiConfig();
@@ -574,8 +714,9 @@ async function main() {
574
714
  // Clean up all spawned servers
575
715
  for (const [stackId, server] of serverPool) {
576
716
  debug(`Killing stack ${stackId}`);
577
- server.process.kill();
717
+ terminateServer(stackId, server, 'stdin-closed');
578
718
  }
719
+ if (cleanupTimer) clearInterval(cleanupTimer);
579
720
  process.exit(0);
580
721
  });
581
722
 
@@ -583,18 +724,24 @@ async function main() {
583
724
  process.on('SIGTERM', () => {
584
725
  log('SIGTERM received, shutting down');
585
726
  for (const [stackId, server] of serverPool) {
586
- server.process.kill();
727
+ terminateServer(stackId, server, 'sigterm');
587
728
  }
729
+ if (cleanupTimer) clearInterval(cleanupTimer);
588
730
  process.exit(0);
589
731
  });
590
732
 
591
733
  process.on('SIGINT', () => {
592
734
  log('SIGINT received, shutting down');
593
735
  for (const [stackId, server] of serverPool) {
594
- server.process.kill();
736
+ terminateServer(stackId, server, 'sigint');
595
737
  }
738
+ if (cleanupTimer) clearInterval(cleanupTimer);
596
739
  process.exit(0);
597
740
  });
741
+
742
+ // Periodic pool cleanup (idle eviction, LRU capping)
743
+ cleanupTimer = setInterval(cleanupServerPool, CLEANUP_INTERVAL_MS);
744
+ if (cleanupTimer.unref) cleanupTimer.unref();
598
745
  }
599
746
 
600
747
  // Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learnrudi/cli",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "description": "RUDI CLI - Install and manage MCP stacks, runtimes, and AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -12,18 +12,26 @@
12
12
  "scripts",
13
13
  "README.md"
14
14
  ],
15
+ "scripts": {
16
+ "start": "node src/index.js",
17
+ "prebuild": "node scripts/generate-manifest.js",
18
+ "build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 && cp src/router-mcp.js dist/router-mcp.js && cp src/packages-manifest.json dist/packages-manifest.json",
19
+ "postinstall": "node scripts/postinstall.js",
20
+ "prepublishOnly": "npm run build",
21
+ "test": "node --test src/__tests__/"
22
+ },
15
23
  "dependencies": {
16
- "better-sqlite3": "^12.5.0",
17
24
  "@learnrudi/core": "1.0.5",
18
- "@learnrudi/embeddings": "0.1.0",
19
25
  "@learnrudi/db": "1.0.2",
26
+ "@learnrudi/embeddings": "0.1.0",
20
27
  "@learnrudi/env": "1.0.1",
21
28
  "@learnrudi/manifest": "1.0.0",
22
29
  "@learnrudi/mcp": "1.0.0",
23
30
  "@learnrudi/registry-client": "1.0.5",
31
+ "@learnrudi/runner": "1.0.1",
24
32
  "@learnrudi/secrets": "1.0.1",
25
33
  "@learnrudi/utils": "1.0.0",
26
- "@learnrudi/runner": "1.0.1"
34
+ "better-sqlite3": "^12.5.0"
27
35
  },
28
36
  "devDependencies": {
29
37
  "esbuild": "^0.27.2"
@@ -49,12 +57,5 @@
49
57
  "publishConfig": {
50
58
  "access": "public"
51
59
  },
52
- "license": "MIT",
53
- "scripts": {
54
- "start": "node src/index.js",
55
- "prebuild": "node scripts/generate-manifest.js",
56
- "build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs --external:better-sqlite3 && cp src/router-mcp.js dist/router-mcp.js && cp src/packages-manifest.json dist/packages-manifest.json",
57
- "postinstall": "node scripts/postinstall.js",
58
- "test": "node --test src/__tests__/"
59
- }
60
- }
60
+ "license": "MIT"
61
+ }