@softerist/heuristic-mcp 3.0.17 → 3.1.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/index.js CHANGED
@@ -7,11 +7,18 @@ import {
7
7
  ListToolsRequestSchema,
8
8
  ListResourcesRequestSchema,
9
9
  ReadResourceRequestSchema,
10
+ RootsListChangedNotificationSchema,
10
11
  } from '@modelcontextprotocol/sdk/types.js';
11
12
  let transformersModule = null;
12
13
  async function getTransformers() {
13
14
  if (!transformersModule) {
14
15
  transformersModule = await import('@huggingface/transformers');
16
+ if (transformersModule?.env) {
17
+ transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
18
+ }
19
+ if (transformersModule?.env) {
20
+ transformersModule.env.cacheDir = path.join(getGlobalCacheDir(), 'xenova');
21
+ }
15
22
  }
16
23
  return transformersModule;
17
24
  }
@@ -22,23 +29,25 @@ import path from 'path';
22
29
  import os from 'os';
23
30
 
24
31
  import { createRequire } from 'module';
25
- import { fileURLToPath } from 'url';
32
+ import { fileURLToPath } from 'url';
33
+ import { getWorkspaceCachePath } from './lib/workspace-cache-key.js';
34
+
26
35
 
27
- // Import package.json for version
28
36
  const require = createRequire(import.meta.url);
29
37
  const packageJson = require('./package.json');
30
38
 
31
- import { loadConfig, getGlobalCacheDir } from './lib/config.js';
39
+ import { loadConfig, getGlobalCacheDir, isNonProjectDirectory } from './lib/config.js';
32
40
  import { clearStaleCaches } from './lib/cache-utils.js';
33
41
  import { enableStderrOnlyLogging, setupFileLogging, getLogFilePath } from './lib/logging.js';
34
42
  import { parseArgs, printHelp } from './lib/cli.js';
35
43
  import { clearCache } from './lib/cache-ops.js';
36
44
  import { logMemory, startMemoryLogger } from './lib/memory-logger.js';
37
- import {
38
- registerSignalHandlers,
39
- setupPidFile,
40
- acquireWorkspaceLock,
41
- } from './lib/server-lifecycle.js';
45
+ import {
46
+ registerSignalHandlers,
47
+ setupPidFile,
48
+ acquireWorkspaceLock,
49
+ stopOtherHeuristicServers,
50
+ } from './lib/server-lifecycle.js';
42
51
 
43
52
  import { EmbeddingsCache } from './lib/cache.js';
44
53
  import { CodebaseIndexer } from './features/index-codebase.js';
@@ -116,17 +125,62 @@ async function printMemorySnapshot(workspaceDir) {
116
125
  return true;
117
126
  }
118
127
 
119
- // Arguments parsed in main()
120
128
 
121
- // Global state
122
- let embedder = null;
123
- let unloadMainEmbedder = null; // Function to unload the embedding model
124
- let cache = null;
125
- let indexer = null;
126
- let hybridSearch = null;
127
- let config = null;
128
- let setWorkspaceFeatureInstance = null;
129
- let autoWorkspaceSwitchPromise = null;
129
+
130
+
131
+ let embedder = null;
132
+ let unloadMainEmbedder = null;
133
+ let cache = null;
134
+ let indexer = null;
135
+ let hybridSearch = null;
136
+ let config = null;
137
+ let workspaceLockAcquired = true;
138
+ let configReadyResolve = null;
139
+ let configInitError = null;
140
+ let configReadyPromise = new Promise((resolve) => {
141
+ configReadyResolve = resolve;
142
+ });
143
+ let setWorkspaceFeatureInstance = null;
144
+ let autoWorkspaceSwitchPromise = null;
145
+ let rootsCapabilitySupported = null;
146
+ let rootsProbeInFlight = null;
147
+ const WORKSPACE_BOUND_TOOL_NAMES = new Set([
148
+ 'a_semantic_search',
149
+ 'b_index_codebase',
150
+ 'c_clear_cache',
151
+ 'd_find_similar_code',
152
+ 'd_ann_config',
153
+ ]);
154
+ const trustedWorkspacePaths = new Set();
155
+
156
+ function shouldRequireTrustedWorkspaceSignalForTool(toolName) {
157
+ return WORKSPACE_BOUND_TOOL_NAMES.has(toolName);
158
+ }
159
+
160
+ function trustWorkspacePath(workspacePath) {
161
+ const normalized = normalizePathForCompare(workspacePath);
162
+ if (normalized) {
163
+ trustedWorkspacePaths.add(normalized);
164
+ }
165
+ }
166
+
167
+ function isCurrentWorkspaceTrusted() {
168
+ if (!config?.searchDirectory) return false;
169
+ return trustedWorkspacePaths.has(normalizePathForCompare(config.searchDirectory));
170
+ }
171
+
172
+ function isToolResponseError(result) {
173
+ if (!result || typeof result !== 'object') return true;
174
+ if (result.isError === true) return true;
175
+ if (!Array.isArray(result.content)) return false;
176
+
177
+ return result.content.some(
178
+ (entry) =>
179
+ entry?.type === 'text' &&
180
+ typeof entry.text === 'string' &&
181
+ entry.text.trim().toLowerCase().startsWith('error:')
182
+ );
183
+ }
130
184
 
131
185
  async function resolveWorkspaceFromEnvValue(rawValue) {
132
186
  if (!rawValue || rawValue.includes('${')) return null;
@@ -140,56 +194,272 @@ async function resolveWorkspaceFromEnvValue(rawValue) {
140
194
  }
141
195
  }
142
196
 
143
- async function detectRuntimeWorkspaceFromEnv() {
144
- for (const key of getWorkspaceEnvKeys()) {
145
- const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
197
+ async function detectRuntimeWorkspaceFromEnv() {
198
+ for (const key of getWorkspaceEnvKeys()) {
199
+ const workspacePath = await resolveWorkspaceFromEnvValue(process.env[key]);
146
200
  if (workspacePath) {
147
201
  return { workspacePath, envKey: key };
148
202
  }
149
203
  }
150
204
 
151
- return null;
152
- }
153
-
154
- async function maybeAutoSwitchWorkspace(request) {
155
- if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return;
156
- if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
157
- if (request?.params?.name === 'f_set_workspace') return;
205
+ return null;
206
+ }
207
+
208
+ function normalizePathForCompare(targetPath) {
209
+ if (!targetPath) return '';
210
+ const resolved = path.resolve(targetPath);
211
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
212
+ }
213
+
214
+ function isProcessAlive(pid) {
215
+ if (!Number.isInteger(pid) || pid <= 0) return false;
216
+ try {
217
+ process.kill(pid, 0);
218
+ return true;
219
+ } catch (err) {
220
+ return err?.code === 'EPERM';
221
+ }
222
+ }
223
+
224
+ async function findAutoAttachWorkspaceCandidate({ excludeCacheDirectory = null } = {}) {
225
+ const cacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
226
+ const normalizedExclude = normalizePathForCompare(excludeCacheDirectory);
227
+
228
+ let cacheDirs = [];
229
+ try {
230
+ cacheDirs = await fs.readdir(cacheRoot, { withFileTypes: true });
231
+ } catch {
232
+ return null;
233
+ }
234
+
235
+ const candidatesByWorkspace = new Map();
236
+ const preferredWorkspaceFromEnv = (await detectRuntimeWorkspaceFromEnv())?.workspacePath ?? null;
237
+ const normalizedPreferred = normalizePathForCompare(preferredWorkspaceFromEnv);
238
+
239
+ const upsertCandidate = (candidate) => {
240
+ const key = normalizePathForCompare(candidate.workspace);
241
+ const existing = candidatesByWorkspace.get(key);
242
+ if (!existing || candidate.rank > existing.rank) {
243
+ candidatesByWorkspace.set(key, candidate);
244
+ }
245
+ };
246
+
247
+ for (const entry of cacheDirs) {
248
+ if (!entry.isDirectory()) continue;
249
+ const cacheDirectory = path.join(cacheRoot, entry.name);
250
+ if (normalizedExclude && normalizePathForCompare(cacheDirectory) === normalizedExclude) continue;
251
+
252
+ const lockPath = path.join(cacheDirectory, 'server.lock.json');
253
+ try {
254
+ const rawLock = await fs.readFile(lockPath, 'utf-8');
255
+ const lock = JSON.parse(rawLock);
256
+ if (!isProcessAlive(lock?.pid)) continue;
257
+ const workspace = path.resolve(lock?.workspace || '');
258
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
259
+ const stats = await fs.stat(workspace).catch(() => null);
260
+ if (!stats?.isDirectory()) continue;
261
+ const rank = Date.parse(lock?.startedAt || '') || 0;
262
+ upsertCandidate({
263
+ workspace,
264
+ cacheDirectory,
265
+ source: `lock:${lock.pid}`,
266
+ rank,
267
+ });
268
+ continue;
269
+ } catch {
270
+
271
+ }
272
+
273
+ const metaPath = path.join(cacheDirectory, 'meta.json');
274
+ try {
275
+ const rawMeta = await fs.readFile(metaPath, 'utf-8');
276
+ const meta = JSON.parse(rawMeta);
277
+ const workspace = path.resolve(meta?.workspace || '');
278
+ if (!workspace || isNonProjectDirectory(workspace)) continue;
279
+ const stats = await fs.stat(workspace).catch(() => null);
280
+ if (!stats?.isDirectory()) continue;
281
+ const filesIndexed = Number(meta?.filesIndexed || 0);
282
+ if (filesIndexed <= 0) continue;
283
+ const rank = Date.parse(meta?.lastSaveTime || '') || 0;
284
+ upsertCandidate({
285
+ workspace,
286
+ cacheDirectory,
287
+ source: 'meta',
288
+ rank,
289
+ });
290
+ } catch {
291
+
292
+ }
293
+ }
294
+
295
+ const candidates = Array.from(candidatesByWorkspace.values());
296
+ if (candidates.length === 0) return null;
297
+ if (normalizedPreferred) {
298
+ const preferred = candidates.find(
299
+ (candidate) => normalizePathForCompare(candidate.workspace) === normalizedPreferred
300
+ );
301
+ if (preferred) return preferred;
302
+ }
303
+ if (candidates.length === 1) return candidates[0];
304
+ return null;
305
+ }
306
+
307
+ async function maybeAutoSwitchWorkspace(request) {
308
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
309
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
310
+ if (request?.params?.name === 'f_set_workspace') return null;
311
+
312
+ const detected = await detectRuntimeWorkspaceFromEnv();
313
+ if (!detected) return null;
314
+ if (isNonProjectDirectory(detected.workspacePath)) {
315
+ console.info(
316
+ `[Server] Ignoring auto-switch candidate from env ${detected.envKey}: non-project path ${detected.workspacePath}`
317
+ );
318
+ return null;
319
+ }
320
+
321
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
322
+ const detectedWorkspace = normalizePathForCompare(detected.workspacePath);
323
+ if (detectedWorkspace === currentWorkspace) return detected.workspacePath;
324
+
325
+ await maybeAutoSwitchWorkspaceToPath(detected.workspacePath, {
326
+ source: `env ${detected.envKey}`,
327
+ reindex: false,
328
+ });
329
+ return detected.workspacePath;
330
+ }
331
+
332
+
333
+
334
+ async function detectWorkspaceFromRoots({ quiet = false } = {}) {
335
+ try {
336
+ const caps = server.getClientCapabilities();
337
+ if (!caps?.roots) {
338
+ rootsCapabilitySupported = false;
339
+ if (!quiet) {
340
+ console.info(
341
+ '[Server] Client does not support roots capability, skipping workspace auto-detection.'
342
+ );
343
+ }
344
+ return null;
345
+ }
346
+ rootsCapabilitySupported = true;
347
+
348
+ const result = await server.listRoots();
349
+ if (!result?.roots?.length) {
350
+ if (!quiet) {
351
+ console.info('[Server] Client returned no roots.');
352
+ }
353
+ return null;
354
+ }
355
+
356
+ if (!quiet) {
357
+ console.info(`[Server] MCP roots received: ${result.roots.map(r => r.uri).join(', ')}`);
358
+ }
359
+
360
+
361
+ const rootPaths = result.roots
362
+ .map(r => r.uri)
363
+ .filter(uri => uri.startsWith('file://'))
364
+ .map(uri => {
365
+ try {
366
+ return fileURLToPath(uri);
367
+ } catch {
368
+ return null;
369
+ }
370
+ })
371
+ .filter(Boolean);
372
+
373
+ if (rootPaths.length === 0) {
374
+ if (!quiet) {
375
+ console.info('[Server] No valid file:// roots found.');
376
+ }
377
+ return null;
378
+ }
379
+
380
+ return path.resolve(rootPaths[0]);
381
+ } catch (err) {
382
+ if (!quiet) {
383
+ console.warn(`[Server] MCP roots detection failed (non-fatal): ${err.message}`);
384
+ }
385
+ return null;
386
+ }
387
+ }
388
+
389
+ async function maybeAutoSwitchWorkspaceToPath(targetWorkspacePath, { source, reindex = false } = {}) {
390
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return;
391
+ if (!targetWorkspacePath) return;
392
+ if (isNonProjectDirectory(targetWorkspacePath)) {
393
+ if (config?.verbose) {
394
+ console.info(
395
+ `[Server] Ignoring auto-switch candidate from ${source || 'unknown'}: non-project path ${targetWorkspacePath}`
396
+ );
397
+ }
398
+ return;
399
+ }
400
+
401
+ const currentWorkspace = normalizePathForCompare(config.searchDirectory);
402
+ const targetWorkspace = normalizePathForCompare(targetWorkspacePath);
403
+ if (targetWorkspace === currentWorkspace) return;
404
+
405
+ if (autoWorkspaceSwitchPromise) {
406
+ await autoWorkspaceSwitchPromise;
407
+ return;
408
+ }
409
+
410
+ autoWorkspaceSwitchPromise = (async () => {
411
+ console.info(
412
+ `[Server] Auto-switching workspace from ${currentWorkspace} to ${targetWorkspacePath} (${source || 'auto'})`
413
+ );
414
+ const result = await setWorkspaceFeatureInstance.execute({
415
+ workspacePath: targetWorkspacePath,
416
+ reindex,
417
+ });
418
+ if (!result.success) {
419
+ console.warn(
420
+ `[Server] Auto workspace switch failed (${source || 'auto'}): ${result.error}`
421
+ );
422
+ return;
423
+ }
424
+ trustWorkspacePath(targetWorkspacePath);
425
+ })();
426
+
427
+ try {
428
+ await autoWorkspaceSwitchPromise;
429
+ } finally {
430
+ autoWorkspaceSwitchPromise = null;
431
+ }
432
+ }
433
+
434
+ async function maybeAutoSwitchWorkspaceFromRoots(request) {
435
+ if (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test') return null;
436
+ if (!setWorkspaceFeatureInstance || !config?.searchDirectory) return null;
437
+ if (request?.params?.name === 'f_set_workspace') return null;
438
+ if (rootsCapabilitySupported === false) return null;
439
+
440
+ if (rootsProbeInFlight) {
441
+ return await rootsProbeInFlight;
442
+ }
443
+
444
+ rootsProbeInFlight = (async () => {
445
+ const rootWorkspace = await detectWorkspaceFromRoots({ quiet: true });
446
+ if (!rootWorkspace) return null;
447
+ await maybeAutoSwitchWorkspaceToPath(rootWorkspace, {
448
+ source: 'roots probe',
449
+ reindex: false,
450
+ });
451
+ return rootWorkspace;
452
+ })();
453
+
454
+ try {
455
+ return await rootsProbeInFlight;
456
+ } finally {
457
+ rootsProbeInFlight = null;
458
+ }
459
+ }
158
460
 
159
- const detected = await detectRuntimeWorkspaceFromEnv();
160
- if (!detected) return;
161
-
162
- const currentWorkspace = path.resolve(config.searchDirectory);
163
- if (detected.workspacePath === currentWorkspace) return;
164
-
165
- if (autoWorkspaceSwitchPromise) {
166
- await autoWorkspaceSwitchPromise;
167
- return;
168
- }
169
-
170
- autoWorkspaceSwitchPromise = (async () => {
171
- console.info(
172
- `[Server] Auto-switching workspace from ${currentWorkspace} to ${detected.workspacePath} (env ${detected.envKey})`
173
- );
174
- const result = await setWorkspaceFeatureInstance.execute({
175
- workspacePath: detected.workspacePath,
176
- reindex: false,
177
- });
178
- if (!result.success) {
179
- console.warn(
180
- `[Server] Auto workspace switch failed (env ${detected.envKey}): ${result.error}`
181
- );
182
- }
183
- })();
184
461
 
185
- try {
186
- await autoWorkspaceSwitchPromise;
187
- } finally {
188
- autoWorkspaceSwitchPromise = null;
189
- }
190
- }
191
462
 
192
- // Feature registry - ordered by priority (semantic_search first as primary tool)
193
463
  const features = [
194
464
  {
195
465
  module: HybridSearchFeature,
@@ -221,19 +491,22 @@ const features = [
221
491
  instance: null,
222
492
  handler: PackageVersionFeature.handleToolCall,
223
493
  },
224
- {
225
- module: SetWorkspaceFeature,
226
- instance: null,
227
- handler: null, // Late-bound after initialization
228
- },
229
- ];
494
+ {
495
+ module: SetWorkspaceFeature,
496
+ instance: null,
497
+ handler: null,
498
+ },
499
+ ];
230
500
 
231
- // Initialize application
232
- async function initialize(workspaceDir) {
233
- // Load configuration with workspace support
501
+
502
+
503
+ async function initialize(workspaceDir) {
504
+
505
+
234
506
  config = await loadConfig(workspaceDir);
235
507
 
236
- // Automatic cache cleanup on startup (Option A)
508
+
509
+
237
510
  if (config.enableCache && config.cacheCleanup?.autoCleanup) {
238
511
  console.info('[Server] Running automatic cache cleanup...');
239
512
  const results = await clearStaleCaches({
@@ -245,7 +518,8 @@ async function initialize(workspaceDir) {
245
518
  }
246
519
  }
247
520
 
248
- // Skip gc check during tests (VITEST env is set)
521
+
522
+
249
523
  const isTest = Boolean(process.env.VITEST || process.env.VITEST_WORKER_ID);
250
524
  if (config.enableExplicitGc && typeof global.gc !== 'function' && !isTest) {
251
525
  console.warn(
@@ -280,7 +554,8 @@ async function initialize(workspaceDir) {
280
554
  env.backends.onnx.wasm.numThreads = ONNX_THREAD_LIMIT;
281
555
  }
282
556
  } catch {
283
- // ignore: fallback tuning is best effort
557
+
558
+
284
559
  }
285
560
  const status = getNativeOnnxStatus();
286
561
  const reason = status?.message || 'onnxruntime-node not available';
@@ -295,27 +570,101 @@ async function initialize(workspaceDir) {
295
570
  config.embeddingProcessPerBatch = true;
296
571
  }
297
572
  }
298
- const lock = await acquireWorkspaceLock({
299
- cacheDirectory: config.cacheDirectory,
300
- workspaceDir: config.searchDirectory,
301
- });
302
- if (!lock.acquired) {
303
- console.warn(
304
- `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
305
- );
306
- console.warn('[Server] Exiting to avoid duplicate model loads.');
307
- process.exit(0);
308
- }
309
- const [pidPath, logPath] = await Promise.all([
310
- setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
311
- setupFileLogging(config),
312
- ]);
573
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
574
+ if (resolutionSource === 'workspace-arg' || resolutionSource === 'env') {
575
+ trustWorkspacePath(config.searchDirectory);
576
+ }
577
+ const isSystemFallbackWorkspace =
578
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
579
+ isNonProjectDirectory(config.searchDirectory);
580
+
581
+ let pidPath = null;
582
+ let logPath = null;
583
+ if (isSystemFallbackWorkspace) {
584
+ workspaceLockAcquired = false;
585
+ console.warn(
586
+ `[Server] System fallback workspace detected (${config.searchDirectory}); running in lightweight read-only mode.`
587
+ );
588
+ console.warn('[Server] Skipping lock/PID/log file setup for fallback workspace.');
589
+ } else {
590
+ if (config.autoStopOtherServersOnStartup !== false) {
591
+ const globalCacheRoot = path.join(getGlobalCacheDir(), 'heuristic-mcp');
592
+ const { killed, failed } = await stopOtherHeuristicServers({
593
+ globalCacheRoot,
594
+ currentCacheDirectory: config.cacheDirectory,
595
+ });
596
+ if (killed.length > 0) {
597
+ const details = killed
598
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
599
+ .join(', ');
600
+ console.info(`[Server] Auto-stopped ${killed.length} stale heuristic-mcp server(s): ${details}`);
601
+ }
602
+ if (failed.length > 0) {
603
+ const details = failed
604
+ .map((entry) => `${entry.pid}${entry.workspace ? ` (${entry.workspace})` : ''}`)
605
+ .join(', ');
606
+ console.warn(
607
+ `[Server] Failed to stop ${failed.length} older heuristic-mcp server(s): ${details}`
608
+ );
609
+ }
610
+ }
611
+
612
+ const lock = await acquireWorkspaceLock({
613
+ cacheDirectory: config.cacheDirectory,
614
+ workspaceDir: config.searchDirectory,
615
+ });
616
+ workspaceLockAcquired = lock.acquired;
617
+ if (!workspaceLockAcquired) {
618
+ console.warn(
619
+ `[Server] Another heuristic-mcp instance is already running for this workspace (pid ${lock.ownerPid ?? 'unknown'}).`
620
+ );
621
+ console.warn(
622
+ '[Server] Starting in secondary read-only mode: background indexing and cache writes are disabled for this instance.'
623
+ );
624
+ }
625
+ [pidPath, logPath] = workspaceLockAcquired
626
+ ? await Promise.all([
627
+ setupPidFile({ pidFileName: PID_FILE_NAME, cacheDirectory: config.cacheDirectory }),
628
+ setupFileLogging(config),
629
+ ])
630
+ : [null, await setupFileLogging(config)];
631
+ }
313
632
  if (logPath) {
314
633
  console.info(`[Logs] Writing server logs to ${logPath}`);
315
634
  console.info(`[Logs] Log viewer: heuristic-mcp --logs --workspace "${config.searchDirectory}"`);
316
635
  }
636
+ {
637
+ const resolution = config.workspaceResolution || {};
638
+ const sourceLabel =
639
+ resolution.source === 'env' && resolution.envKey
640
+ ? `env:${resolution.envKey}`
641
+ : resolution.source || 'unknown';
642
+ const baseLabel = resolution.baseDirectory || '(unknown)';
643
+ const searchLabel = resolution.searchDirectory || config.searchDirectory;
644
+ const overrideLabel = resolution.searchDirectoryFromConfig ? 'yes' : 'no';
645
+ console.info(
646
+ `[Server] Workspace resolved: source=${sourceLabel}, base=${baseLabel}, search=${searchLabel}, configOverride=${overrideLabel}`
647
+ );
648
+ if (resolution.fromPath) {
649
+ console.info(`[Server] Workspace resolution origin cwd: ${resolution.fromPath}`);
650
+ }
651
+
652
+ const workspaceEnvProbe = Array.isArray(resolution.workspaceEnvProbe)
653
+ ? resolution.workspaceEnvProbe
654
+ : [];
655
+ if (workspaceEnvProbe.length > 0) {
656
+ const probePreview = workspaceEnvProbe.slice(0, 8).map((entry) => {
657
+ const scope = entry?.priority ? 'priority' : 'diagnostic';
658
+ const status = entry?.resolvedPath ? `valid:${entry.resolvedPath}` : `invalid:${entry?.value}`;
659
+ return `${entry?.key}[${scope}]=${status}`;
660
+ });
661
+ const suffix = workspaceEnvProbe.length > 8 ? ` (+${workspaceEnvProbe.length - 8} more)` : '';
662
+ console.info(`[Server] Workspace env probe: ${probePreview.join('; ')}${suffix}`);
663
+ }
664
+ }
317
665
 
318
- // Log effective configuration for debugging
666
+
667
+
319
668
  console.info(
320
669
  `[Server] Config: workerThreads=${config.workerThreads}, embeddingProcessPerBatch=${config.embeddingProcessPerBatch}`
321
670
  );
@@ -327,14 +676,17 @@ async function initialize(workspaceDir) {
327
676
  console.info(`[Server] PID file: ${pidPath}`);
328
677
  }
329
678
 
330
- // Log cache directory logic for debugging
679
+
680
+
331
681
  try {
332
682
  const globalCache = path.join(getGlobalCacheDir(), 'heuristic-mcp');
333
683
  const localCache = path.join(process.cwd(), '.heuristic-mcp');
334
684
  console.info(`[Server] Cache debug: Global=${globalCache}, Local=${localCache}`);
335
685
  console.info(`[Server] Process CWD: ${process.cwd()}`);
686
+ console.info(`[Server] Resolved workspace: ${config.searchDirectory} (via ${config.workspaceResolution?.source || 'unknown'})`);
336
687
  } catch (_e) {
337
- /* ignore */
688
+
689
+
338
690
  }
339
691
 
340
692
  let stopStartupMemory = null;
@@ -343,7 +695,8 @@ async function initialize(workspaceDir) {
343
695
  stopStartupMemory = startMemoryLogger('[Server] Memory (startup)', MEMORY_LOG_INTERVAL_MS);
344
696
  }
345
697
 
346
- // Ensure search directory exists
698
+
699
+
347
700
  try {
348
701
  await fs.access(config.searchDirectory);
349
702
  } catch {
@@ -351,7 +704,8 @@ async function initialize(workspaceDir) {
351
704
  process.exit(1);
352
705
  }
353
706
 
354
- // Create a transparent lazy-loading embedder closure
707
+
708
+
355
709
  console.info('[Server] Initializing features...');
356
710
  let cachedEmbedderPromise = null;
357
711
  const lazyEmbedder = async (...args) => {
@@ -384,7 +738,7 @@ async function initialize(workspaceDir) {
384
738
  return model(...args);
385
739
  };
386
740
 
387
- // Unload the main process embedding model to free memory
741
+
388
742
  const unloader = async () => {
389
743
  if (!cachedEmbedderPromise) return false;
390
744
  try {
@@ -409,7 +763,7 @@ async function initialize(workspaceDir) {
409
763
  };
410
764
 
411
765
  embedder = lazyEmbedder;
412
- unloadMainEmbedder = unloader; // Store in module scope for tool handler access
766
+ unloadMainEmbedder = unloader;
413
767
  const preloadEmbeddingModel = async () => {
414
768
  if (config.preloadEmbeddingModel === false) return;
415
769
  try {
@@ -420,29 +774,29 @@ async function initialize(workspaceDir) {
420
774
  }
421
775
  };
422
776
 
423
- // NOTE: We no longer auto-load in verbose mode when preloadEmbeddingModel=false.
424
- // The model will be loaded lazily on first search or by child processes during indexing.
777
+
778
+
425
779
 
426
- // Initialize cache (load deferred until after server is ready)
780
+
427
781
  cache = new EmbeddingsCache(config);
428
782
  console.info(`[Server] Cache directory: ${config.cacheDirectory}`);
429
783
 
430
- // Initialize features
784
+
431
785
  indexer = new CodebaseIndexer(embedder, cache, config, server);
432
786
  hybridSearch = new HybridSearch(embedder, cache, config);
433
787
  const cacheClearer = new ClearCacheFeature.CacheClearer(embedder, cache, config, indexer);
434
788
  const findSimilarCode = new FindSimilarCodeFeature.FindSimilarCode(embedder, cache, config);
435
789
  const annConfig = new AnnConfigFeature.AnnConfigTool(cache, config);
436
790
 
437
- // Store feature instances (matches features array order)
791
+
438
792
  features[0].instance = hybridSearch;
439
793
  features[1].instance = indexer;
440
794
  features[2].instance = cacheClearer;
441
795
  features[3].instance = findSimilarCode;
442
796
  features[4].instance = annConfig;
443
- // Features 5 (PackageVersion) doesn't need instance
797
+
444
798
 
445
- // Initialize SetWorkspace feature with shared state
799
+
446
800
  const setWorkspaceInstance = new SetWorkspaceFeature.SetWorkspaceFeature(
447
801
  config,
448
802
  cache,
@@ -453,34 +807,99 @@ async function initialize(workspaceDir) {
453
807
  features[6].instance = setWorkspaceInstance;
454
808
  features[6].handler = SetWorkspaceFeature.createHandleToolCall(setWorkspaceInstance);
455
809
 
456
- // Attach hybridSearch to server for cross-feature access (e.g. cache invalidation)
457
- server.hybridSearch = hybridSearch;
458
-
459
- const startBackgroundTasks = async () => {
460
- // Keep startup responsive: do not block server readiness on model preload.
461
- void preloadEmbeddingModel();
462
-
810
+
811
+ server.hybridSearch = hybridSearch;
812
+
813
+ const startBackgroundTasks = async () => {
814
+ const stopStartupMemoryLogger = () => {
815
+ if (stopStartupMemory) {
816
+ stopStartupMemory();
817
+ }
818
+ };
819
+ const tryAutoAttachWorkspaceCache = async (reason) => {
820
+ const candidate = await findAutoAttachWorkspaceCandidate({
821
+ excludeCacheDirectory: config.cacheDirectory,
822
+ });
823
+ if (!candidate) {
824
+ console.warn(
825
+ `[Server] Auto-attach skipped (${reason}): no unambiguous workspace cache candidate found.`
826
+ );
827
+ return false;
828
+ }
829
+
830
+ config.searchDirectory = candidate.workspace;
831
+ config.cacheDirectory = candidate.cacheDirectory;
832
+ await fs.mkdir(config.cacheDirectory, { recursive: true });
833
+ await cache.load();
834
+ console.info(
835
+ `[Server] Auto-attached workspace cache (${reason}): ${candidate.workspace} via ${candidate.source}`
836
+ );
837
+ if (config.verbose) {
838
+ logMemory('[Server] Memory (after cache load)');
839
+ }
840
+ return true;
841
+ };
842
+
843
+ const resolutionSource = config.workspaceResolution?.source || 'unknown';
844
+ const isSystemFallback =
845
+ (resolutionSource === 'cwd' || resolutionSource === 'cwd-root-search') &&
846
+ isNonProjectDirectory(config.searchDirectory);
847
+
848
+ if (isSystemFallback) {
849
+ try {
850
+ console.warn(
851
+ `[Server] Detected system fallback workspace: ${config.searchDirectory}. Attempting cache auto-attach.`
852
+ );
853
+ const attached = await tryAutoAttachWorkspaceCache('system-fallback');
854
+ if (!attached) {
855
+ console.warn(
856
+ '[Server] Waiting for a proper workspace root (MCP roots, env vars, or f_set_workspace).'
857
+ );
858
+ }
859
+ } finally {
860
+ stopStartupMemoryLogger();
861
+ }
862
+ return;
863
+ }
864
+
865
+ if (!workspaceLockAcquired) {
866
+ try {
867
+ console.info('[Server] Secondary instance detected; loading cache in read-only mode.');
868
+ await cache.load();
869
+ if (cache.getStoreSize() === 0) {
870
+ await tryAutoAttachWorkspaceCache('secondary-empty-cache');
871
+ }
872
+ if (config.verbose) {
873
+ logMemory('[Server] Memory (after cache load)');
874
+ }
875
+ } finally {
876
+ stopStartupMemoryLogger();
877
+ }
878
+ console.info('[Server] Secondary instance ready; skipping background indexing.');
879
+ return;
880
+ }
881
+
882
+ void preloadEmbeddingModel();
883
+
463
884
  try {
464
885
  console.info('[Server] Loading cache (deferred)...');
465
- await cache.load();
466
- if (config.verbose) {
467
- logMemory('[Server] Memory (after cache load)');
468
- }
469
- } finally {
470
- if (stopStartupMemory) {
471
- stopStartupMemory();
472
- }
473
- }
474
-
475
- // Start indexing in background (non-blocking)
886
+ await cache.load();
887
+ if (config.verbose) {
888
+ logMemory('[Server] Memory (after cache load)');
889
+ }
890
+ } finally {
891
+ stopStartupMemoryLogger();
892
+ }
893
+
894
+
476
895
  console.info('[Server] Starting background indexing (delayed)...');
477
896
 
478
- // Slight delay to allow server to bind and accept first request if immediate
897
+
479
898
  setTimeout(() => {
480
899
  indexer
481
900
  .indexAll()
482
901
  .then(() => {
483
- // Only start file watcher if explicitly enabled in config
902
+
484
903
  if (config.watchFiles) {
485
904
  indexer.setupFileWatcher();
486
905
  }
@@ -494,7 +913,7 @@ async function initialize(workspaceDir) {
494
913
  return { startBackgroundTasks, config };
495
914
  }
496
915
 
497
- // Setup MCP server
916
+
498
917
  const server = new Server(
499
918
  {
500
919
  name: 'heuristic-mcp',
@@ -508,19 +927,46 @@ const server = new Server(
508
927
  }
509
928
  );
510
929
 
511
- // Handle resources/list
512
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
513
- return await handleListResources(config);
514
- });
515
930
 
516
- // Handle resources/read
517
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
518
- return await handleReadResource(request.params.uri, config);
519
- });
931
+ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
932
+ console.info('[Server] Received roots/list_changed notification from client.');
933
+ const newRoot = await detectWorkspaceFromRoots();
934
+ if (newRoot) {
935
+ await maybeAutoSwitchWorkspaceToPath(newRoot, {
936
+ source: 'roots changed',
937
+ reindex: true,
938
+ });
939
+ }
940
+ });
941
+
942
+
520
943
 
521
- // Register tools from all features
522
- server.setRequestHandler(ListToolsRequestSchema, async () => {
523
- const tools = [];
944
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
945
+ await configReadyPromise;
946
+ if (configInitError || !config) {
947
+ throw configInitError ?? new Error('Server configuration is not initialized');
948
+ }
949
+ return await handleListResources(config);
950
+ });
951
+
952
+
953
+
954
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
955
+ await configReadyPromise;
956
+ if (configInitError || !config) {
957
+ throw configInitError ?? new Error('Server configuration is not initialized');
958
+ }
959
+ return await handleReadResource(request.params.uri, config);
960
+ });
961
+
962
+
963
+
964
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
965
+ await configReadyPromise;
966
+ if (configInitError || !config) {
967
+ throw configInitError ?? new Error('Server configuration is not initialized');
968
+ }
969
+ const tools = [];
524
970
 
525
971
  for (const feature of features) {
526
972
  const toolDef = feature.module.getToolDefinition(config);
@@ -530,15 +976,142 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
530
976
  return { tools };
531
977
  });
532
978
 
533
- // Handle tool calls
534
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
535
- await maybeAutoSwitchWorkspace(request);
536
979
 
537
- for (const feature of features) {
980
+
981
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
982
+ await configReadyPromise;
983
+ if (configInitError || !config) {
984
+ return {
985
+ content: [
986
+ {
987
+ type: 'text',
988
+ text: `Server initialization failed: ${configInitError?.message || 'configuration not available'}`,
989
+ },
990
+ ],
991
+ isError: true,
992
+ };
993
+ }
994
+
995
+ if (!workspaceLockAcquired && request.params?.name === 'f_set_workspace') {
996
+ const args = request.params?.arguments || {};
997
+ const workspacePath = args.workspacePath;
998
+ const reindex = args.reindex !== false;
999
+ if (typeof workspacePath !== 'string' || workspacePath.trim().length === 0) {
1000
+ return {
1001
+ content: [{ type: 'text', text: 'Error: workspacePath is required.' }],
1002
+ isError: true,
1003
+ };
1004
+ }
1005
+ if (reindex) {
1006
+ return {
1007
+ content: [
1008
+ {
1009
+ type: 'text',
1010
+ text: 'This server instance is in secondary read-only mode. Set reindex=false to attach cache only.',
1011
+ },
1012
+ ],
1013
+ isError: true,
1014
+ };
1015
+ }
1016
+ const normalizedPath = path.resolve(workspacePath);
1017
+ try {
1018
+ const stats = await fs.stat(normalizedPath);
1019
+ if (!stats.isDirectory()) {
1020
+ return {
1021
+ content: [{ type: 'text', text: `Error: Path is not a directory: ${normalizedPath}` }],
1022
+ isError: true,
1023
+ };
1024
+ }
1025
+ } catch (err) {
1026
+ return {
1027
+ content: [
1028
+ {
1029
+ type: 'text',
1030
+ text: `Error: Cannot access directory ${normalizedPath}: ${err.message}`,
1031
+ },
1032
+ ],
1033
+ isError: true,
1034
+ };
1035
+ }
1036
+
1037
+ config.searchDirectory = normalizedPath;
1038
+ config.cacheDirectory = getWorkspaceCachePath(normalizedPath, getGlobalCacheDir());
1039
+ try {
1040
+ await fs.mkdir(config.cacheDirectory, { recursive: true });
1041
+ await cache.load();
1042
+ trustWorkspacePath(normalizedPath);
1043
+ return {
1044
+ content: [
1045
+ {
1046
+ type: 'text',
1047
+ text: `Attached in read-only mode to workspace cache: ${normalizedPath}`,
1048
+ },
1049
+ ],
1050
+ };
1051
+ } catch (err) {
1052
+ return {
1053
+ content: [
1054
+ {
1055
+ type: 'text',
1056
+ text: `Error: Failed to attach cache for ${normalizedPath}: ${err.message}`,
1057
+ },
1058
+ ],
1059
+ isError: true,
1060
+ };
1061
+ }
1062
+ }
1063
+
1064
+ if (
1065
+ !workspaceLockAcquired &&
1066
+ ['b_index_codebase', 'c_clear_cache'].includes(request.params?.name)
1067
+ ) {
1068
+ return {
1069
+ content: [
1070
+ {
1071
+ type: 'text',
1072
+ text: 'This server instance is in secondary read-only mode. Use the primary instance for indexing/cache mutation tools.',
1073
+ },
1074
+ ],
1075
+ isError: true,
1076
+ };
1077
+ }
1078
+ const detectedFromRoots = await maybeAutoSwitchWorkspaceFromRoots(request);
1079
+ const detectedFromEnv = await maybeAutoSwitchWorkspace(request);
1080
+ if (detectedFromRoots) {
1081
+ trustWorkspacePath(detectedFromRoots);
1082
+ }
1083
+ if (detectedFromEnv) {
1084
+ trustWorkspacePath(detectedFromEnv);
1085
+ }
1086
+
1087
+ const toolName = request.params?.name;
1088
+ if (
1089
+ config.requireTrustedWorkspaceSignalForTools === true &&
1090
+ shouldRequireTrustedWorkspaceSignalForTool(toolName) &&
1091
+ !detectedFromRoots &&
1092
+ !detectedFromEnv &&
1093
+ !isCurrentWorkspaceTrusted()
1094
+ ) {
1095
+ return {
1096
+ content: [
1097
+ {
1098
+ type: 'text',
1099
+ text:
1100
+ `Workspace context appears stale for "${toolName}" (current: "${config.searchDirectory}"). ` +
1101
+ 'Please reload your IDE window and retry. ' +
1102
+ 'If needed, call MCP tool "f_set_workspace" from your chat/client with your opened folder path.',
1103
+ },
1104
+ ],
1105
+ isError: true,
1106
+ };
1107
+ }
1108
+
1109
+ for (const feature of features) {
538
1110
  const toolDef = feature.module.getToolDefinition(config);
539
1111
 
540
1112
  if (request.params.name === toolDef.name) {
541
- // Safety check: handler may be null if initialization is incomplete
1113
+
1114
+
542
1115
  if (typeof feature.handler !== 'function') {
543
1116
  return {
544
1117
  content: [{
@@ -547,14 +1120,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
547
1120
  }],
548
1121
  isError: true,
549
1122
  };
550
- }
551
- const result = await feature.handler(request, feature.instance);
1123
+ }
1124
+ const result = await feature.handler(request, feature.instance);
1125
+ if (toolDef.name === 'f_set_workspace' && !isToolResponseError(result)) {
1126
+ trustWorkspacePath(config.searchDirectory);
1127
+ }
1128
+
1129
+
1130
+
1131
+
552
1132
 
553
- // Unload embedding model after search-related tools to free memory
554
- // Tools that use embedder: a_semantic_search, d_find_similar_code
555
1133
  const searchTools = ['a_semantic_search', 'd_find_similar_code'];
556
1134
  if (config.unloadModelAfterSearch && searchTools.includes(toolDef.name)) {
557
- // Defer unload slightly to not block response, use setImmediate for non-blocking
1135
+
1136
+
558
1137
  setImmediate(async () => {
559
1138
  if (typeof unloadMainEmbedder === 'function') {
560
1139
  await unloadMainEmbedder();
@@ -577,7 +1156,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
577
1156
  };
578
1157
  });
579
1158
 
580
- // Main entry point
1159
+
1160
+
581
1161
  export async function main(argv = process.argv) {
582
1162
  const parsed = parseArgs(argv);
583
1163
  const {
@@ -641,19 +1221,23 @@ export async function main(argv = process.argv) {
641
1221
  process.exit(0);
642
1222
  }
643
1223
 
644
- // --cache command (cache-only, no server status)
1224
+
1225
+
645
1226
  if (wantsCache) {
646
1227
  await status({ fix: wantsClean, cacheOnly: true, workspaceDir });
647
1228
  process.exit(0);
648
1229
  }
649
1230
 
650
- // --clear <cache_id> command (remove specific cache by ID)
1231
+
1232
+
651
1233
  const clearIndex = parsed.rawArgs.indexOf('--clear');
652
1234
  if (clearIndex !== -1) {
653
1235
  const cacheId = parsed.rawArgs[clearIndex + 1];
654
1236
  if (cacheId && !cacheId.startsWith('--')) {
655
- // Remove specific cache by ID
656
- // Determine platform-appropriate cache directory
1237
+
1238
+
1239
+
1240
+
657
1241
  let cacheHome;
658
1242
  if (process.platform === 'win32') {
659
1243
  cacheHome = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
@@ -697,7 +1281,8 @@ export async function main(argv = process.argv) {
697
1281
  }
698
1282
  process.exit(0);
699
1283
  }
700
- // If --clear with no arg, fall through to --clear-cache behavior
1284
+
1285
+
701
1286
  }
702
1287
 
703
1288
  if (wantsClearCache) {
@@ -740,34 +1325,88 @@ export async function main(argv = process.argv) {
740
1325
  }
741
1326
 
742
1327
  registerSignalHandlers(requestShutdown);
743
- // NOTE: We intentionally do NOT shut down on stdin close.
744
- // When an IDE restarts, it may briefly close stdin then reconnect.
745
- // The server should remain running to preserve cache and be ready for reconnection.
746
- // Use SIGINT/SIGTERM or --stop command for intentional shutdown.
747
- const { startBackgroundTasks } = await initialize(workspaceDir);
1328
+
1329
+
1330
+
1331
+
748
1332
 
749
- // (Blocking init moved below)
1333
+
1334
+ const detectedRootPromise = new Promise((resolve) => {
1335
+ const HANDSHAKE_TIMEOUT_MS = 1000;
1336
+ let settled = false;
1337
+ const resolveOnce = (value) => {
1338
+ if (settled) return;
1339
+ settled = true;
1340
+ resolve(value);
1341
+ };
1342
+
1343
+ const timer = setTimeout(() => {
1344
+ console.warn(`[Server] MCP handshake timed out after ${HANDSHAKE_TIMEOUT_MS}ms, proceeding without roots.`);
1345
+ resolveOnce(null);
1346
+ }, HANDSHAKE_TIMEOUT_MS);
1347
+
1348
+ server.oninitialized = async () => {
1349
+ clearTimeout(timer);
1350
+ console.info('[Server] MCP handshake complete.');
1351
+ const root = await detectWorkspaceFromRoots();
1352
+ resolveOnce(root);
1353
+ };
1354
+ });
1355
+
1356
+ const transport = new StdioServerTransport();
1357
+ await server.connect(transport);
1358
+ console.info('[Server] MCP transport connected.');
1359
+ if (isServerMode) {
1360
+ process.stdin?.on?.('end', () => requestShutdown('stdin-end'));
1361
+ process.stdin?.on?.('close', () => requestShutdown('stdin-close'));
1362
+ process.stdout?.on?.('error', (err) => {
1363
+ if (err?.code === 'EPIPE') {
1364
+ requestShutdown('stdout-epipe');
1365
+ }
1366
+ });
1367
+ }
1368
+
1369
+
1370
+
1371
+ const detectedRoot = await detectedRootPromise;
750
1372
 
751
- const transport = new StdioServerTransport();
752
- await server.connect(transport);
1373
+
1374
+ const effectiveWorkspace = detectedRoot || workspaceDir;
1375
+ if (detectedRoot) {
1376
+ console.info(`[Server] Using workspace from MCP roots: ${detectedRoot}`);
1377
+ }
1378
+ const initPromise = initialize(effectiveWorkspace);
1379
+ const initWithResolve = initPromise
1380
+ .then((result) => {
1381
+ configReadyResolve();
1382
+ return result;
1383
+ })
1384
+ .catch((err) => {
1385
+ configInitError = err;
1386
+ configReadyResolve();
1387
+ throw err;
1388
+ });
1389
+ const { startBackgroundTasks } = await initWithResolve;
753
1390
 
754
- console.info('[Server] MCP transport connected.');
755
1391
  console.info('[Server] Heuristic MCP server started.');
756
1392
 
757
- // Load cache and start indexing in background AFTER server is ready
1393
+
1394
+
758
1395
  void startBackgroundTasks().catch((err) => {
759
1396
  console.error(`[Server] Background task error: ${err.message}`);
760
1397
  });
761
1398
  console.info('[Server] MCP server is now fully ready to accept requests.');
762
1399
  }
763
1400
 
764
- // Graceful shutdown
1401
+
1402
+
765
1403
  async function gracefulShutdown(signal) {
766
1404
  console.info(`[Server] Received ${signal}, shutting down gracefully...`);
767
1405
 
768
1406
  const cleanupTasks = [];
769
1407
 
770
- // Stop file watcher
1408
+
1409
+
771
1410
  if (indexer && indexer.watcher) {
772
1411
  cleanupTasks.push(
773
1412
  indexer.watcher
@@ -777,7 +1416,8 @@ async function gracefulShutdown(signal) {
777
1416
  );
778
1417
  }
779
1418
 
780
- // Give workers time to finish current batch
1419
+
1420
+
781
1421
  if (indexer && indexer.terminateWorkers) {
782
1422
  cleanupTasks.push(
783
1423
  (async () => {
@@ -788,20 +1428,26 @@ async function gracefulShutdown(signal) {
788
1428
  );
789
1429
  }
790
1430
 
791
- // Save cache
792
- if (cache) {
793
- cleanupTasks.push(
794
- cache
795
- .save()
796
- .then(() => console.info('[Server] Cache saved'))
797
- .catch((err) => console.error(`[Server] Failed to save cache: ${err.message}`))
798
- );
799
- }
1431
+
1432
+
1433
+ if (cache) {
1434
+ if (!workspaceLockAcquired) {
1435
+ console.info('[Server] Secondary/fallback mode: skipping cache save.');
1436
+ } else {
1437
+ cleanupTasks.push(
1438
+ cache
1439
+ .save()
1440
+ .then(() => console.info('[Server] Cache saved'))
1441
+ .catch((err) => console.error(`[Server] Failed to save cache: ${err.message}`))
1442
+ );
1443
+ }
1444
+ }
800
1445
 
801
1446
  await Promise.allSettled(cleanupTasks);
802
1447
  console.info('[Server] Goodbye!');
803
1448
 
804
- // Allow stdio buffers to flush
1449
+
1450
+
805
1451
  setTimeout(() => process.exit(0), 100);
806
1452
  }
807
1453