fraim 2.0.351 → 2.0.352

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.
@@ -227,6 +227,7 @@ function buildPathWithManagedAgentBins(basePath) {
227
227
  // launches already inherit the registry-composed PATH, so this returns null immediately on
228
228
  // win32 rather than attempting a login-shell equivalent that doesn't exist.
229
229
  const LOGIN_SHELL_PATH_TIMEOUT_MS = 3000;
230
+ const LOGIN_SHELL_PATH_CACHE_TTL_MS = 60_000;
230
231
  function defaultResolveLoginShellPath() {
231
232
  if (process.platform === 'win32')
232
233
  return null;
@@ -279,18 +280,20 @@ let loginShellPathResolverOverride = null;
279
280
  */
280
281
  function __setLoginShellPathResolverForTests(resolver) {
281
282
  loginShellPathResolverOverride = resolver;
282
- cachedLoginShellPath = undefined;
283
- cachedNpmGlobalBinDirsForRecovery.clear();
283
+ __clearLoginShellPathCacheForTests();
284
284
  }
285
285
  let cachedLoginShellPath;
286
+ let cachedLoginShellPathAtMs = 0;
286
287
  /**
287
- * Resolves and memoizes the PATH the user's own login shell would produce. Computed at most
288
- * once per process (AC-A5: bounded, fixed cost) — this feeds every agent-resolution call, so
289
- * recomputing per call would multiply one shell spawn by the number of agents probed.
288
+ * Resolves the user's login PATH at most once per minute, including failed reads. A
289
+ * running Hub can discover later installations without spawning a shell per agent probe.
290
290
  */
291
291
  function resolveLoginShellPathCached() {
292
- if (cachedLoginShellPath !== undefined)
292
+ const nowMs = Date.now();
293
+ const ageMs = nowMs - cachedLoginShellPathAtMs;
294
+ if (cachedLoginShellPath !== undefined && ageMs >= 0 && ageMs < LOGIN_SHELL_PATH_CACHE_TTL_MS) {
293
295
  return cachedLoginShellPath;
296
+ }
294
297
  const resolver = loginShellPathResolverOverride ?? defaultResolveLoginShellPath;
295
298
  // AC-A5: a resolver failing must degrade to "no recovery", never propagate. The default
296
299
  // resolver already guards its own spawnSync internally; this also protects against an
@@ -301,13 +304,20 @@ function resolveLoginShellPathCached() {
301
304
  catch {
302
305
  cachedLoginShellPath = null;
303
306
  }
307
+ cachedLoginShellPathAtMs = nowMs;
308
+ // npm's configured global prefix can change even when the recovered PATH does not.
309
+ // Refresh both tiers together so an old prefix cannot outlive login-PATH recovery.
310
+ cachedNpmGlobalBinDirsForRecovery.clear();
304
311
  return cachedLoginShellPath;
305
312
  }
306
313
  /** Test seam only. Clears the memoized login-shell PATH so the next call recomputes. */
307
314
  function __clearLoginShellPathCacheForTests() {
308
315
  cachedLoginShellPath = undefined;
316
+ cachedLoginShellPathAtMs = 0;
317
+ cachedNpmGlobalBinDirsForRecovery.clear();
309
318
  }
310
319
  const cachedNpmGlobalBinDirsForRecovery = new Map();
320
+ const NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES = 64;
311
321
  /**
312
322
  * Memoized wrapper around resolveNpmGlobalBinDirs(), scoped to buildRecoveredAgentPath()'s
313
323
  * own use only — NOT a change to resolveNpmGlobalBinDirs() itself, which fraim doctor and the
@@ -321,8 +331,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
321
331
  * and because each call sits inside a Promise executor that runs synchronously up to its own
322
332
  * spawnSync, the "concurrent" probes actually serialized behind it — up to 5 agents × 5s of
323
333
  * blocking spawnSync is exactly the event-loop-freezing regression issue #1010 eliminated,
324
- * reintroduced one layer deeper. Memoizing once per process (like resolveLoginShellPathCached
325
- * above) restores the bound this repo's own #1010 postmortem requires.
334
+ * reintroduced one layer deeper. Memoizing between login-PATH refreshes keeps repeated
335
+ * probes in the same round from duplicating that work.
326
336
  *
327
337
  * Keyed by `basePath` (not a single scalar): `resolveManagedCommand()` also routes through this
328
338
  * cache now, and unlike `detectEmployeesAsync()`'s repeated same-ambient-PATH probing, its
@@ -330,7 +340,8 @@ const cachedNpmGlobalBinDirsForRecovery = new Map();
330
340
  * isolated tests that swap `FRAIM_USER_DIR`/`PATH` per test case). A single scalar cache would
331
341
  * let the FIRST basePath's answer silently leak into every later, differently-scoped lookup —
332
342
  * concretely, a real machine's actual npm-global `codex` shadowing a test's own isolated managed
333
- * shim. Per-basePath keys keep the "once per distinct PATH" bound without that cross-contamination.
343
+ * shim. Per-basePath keys avoid that cross-contamination; a size limit bounds memory when
344
+ * callers supply many distinct PATHs before the next refresh.
334
345
  */
335
346
  function resolveNpmGlobalBinDirsCached(basePath) {
336
347
  const cached = cachedNpmGlobalBinDirsForRecovery.get(basePath);
@@ -343,6 +354,11 @@ function resolveNpmGlobalBinDirsCached(basePath) {
343
354
  catch {
344
355
  dirs = [];
345
356
  }
357
+ if (cachedNpmGlobalBinDirsForRecovery.size >= NPM_GLOBAL_RECOVERY_CACHE_MAX_ENTRIES) {
358
+ const oldestKey = cachedNpmGlobalBinDirsForRecovery.keys().next().value;
359
+ if (oldestKey !== undefined)
360
+ cachedNpmGlobalBinDirsForRecovery.delete(oldestKey);
361
+ }
346
362
  cachedNpmGlobalBinDirsForRecovery.set(basePath, dirs);
347
363
  return dirs;
348
364
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.351",
3
+ "version": "2.0.352",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {