@juspay/neurolink 9.87.3 → 9.87.4

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.
@@ -19,6 +19,8 @@ import { logger } from "../../lib/utils/logger.js";
19
19
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
20
20
  import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
21
21
  import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
22
+ import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../lib/proxy/proxyActivity.js";
23
+ import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, } from "../../lib/proxy/globalInstaller.js";
22
24
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
23
25
  import { createRequire } from "node:module";
24
26
  import { fileURLToPath } from "node:url";
@@ -137,7 +139,7 @@ function isLaunchdManagedProcess() {
137
139
  return process.platform === "darwin" && process.ppid === 1;
138
140
  }
139
141
  function isProxyAutoUpdateEnabled(value = process.env.NEUROLINK_PROXY_AUTO_UPDATE) {
140
- return ["1", "on", "true"].includes((value ?? "").trim().toLowerCase());
142
+ return !["0", "off", "false"].includes((value ?? "").trim().toLowerCase());
141
143
  }
142
144
  /** Keys we manage in Claude Code's settings.env */
143
145
  const PROXY_MANAGED_KEYS = ["ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH"];
@@ -338,6 +340,41 @@ async function isProxyHealthy(host, port, timeoutMs) {
338
340
  return false;
339
341
  }
340
342
  }
343
+ async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
344
+ try {
345
+ const response = await fetch(`http://${host}:${port}/status`, {
346
+ signal: AbortSignal.timeout(timeoutMs),
347
+ });
348
+ if (!response.ok) {
349
+ return null;
350
+ }
351
+ const payload = (await response.json());
352
+ const activeRequests = Number(payload.activity?.activeRequests);
353
+ if (!Number.isFinite(activeRequests) || activeRequests < 0) {
354
+ return null;
355
+ }
356
+ return {
357
+ activeRequests,
358
+ lastActivityAt: typeof payload.activity?.lastActivityAt === "string"
359
+ ? payload.activity.lastActivityAt
360
+ : null,
361
+ };
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ function isSafeUpdateWindow(activity, quietThresholdMs, nowMs = Date.now()) {
368
+ if (activity.activeRequests > 0) {
369
+ return false;
370
+ }
371
+ if (!activity.lastActivityAt) {
372
+ return true;
373
+ }
374
+ const lastActivityMs = Date.parse(activity.lastActivityAt);
375
+ return (Number.isFinite(lastActivityMs) &&
376
+ nowMs - lastActivityMs >= quietThresholdMs);
377
+ }
341
378
  // ---------------------------------------------------------------------------
342
379
  // Stable entrypoint for launchd
343
380
  // ---------------------------------------------------------------------------
@@ -437,113 +474,6 @@ exit 127
437
474
  writeFileSync(TRAMPOLINE_PATH, script, { mode: 0o755 });
438
475
  chmodSync(TRAMPOLINE_PATH, 0o755);
439
476
  }
440
- /**
441
- * Check whether a pnpm binary can install into the global store.
442
- *
443
- * Multiple pnpm major versions can coexist (e.g., standalone v8 + nvm v10).
444
- * They use different store layouts (`store/v10` vs `store/v10/v3`), so a
445
- * pnpm that passes `--version` may still fail `pnpm add -g` with
446
- * ERR_PNPM_UNEXPECTED_STORE. We detect this by running `pnpm root -g` and
447
- * checking whether the resolved global root directory actually exists on disk.
448
- */
449
- function canInstallGlobally(pnpmPath) {
450
- try {
451
- const { execFileSync } = _require("node:child_process");
452
- const { existsSync } = _require("fs");
453
- const globalRoot = execFileSync(pnpmPath, ["root", "-g"], {
454
- encoding: "utf8",
455
- timeout: 10_000,
456
- stdio: ["ignore", "pipe", "ignore"],
457
- }).trim();
458
- // If the global root exists, this pnpm version is compatible with
459
- // the current store layout and can install packages there.
460
- return !!globalRoot && existsSync(globalRoot);
461
- }
462
- catch {
463
- return false;
464
- }
465
- }
466
- /**
467
- * Resolve the `pnpm` binary defensively.
468
- *
469
- * Tries multiple candidates in order of preference. Each candidate must:
470
- * 1. Respond to `pnpm --version` (binary works)
471
- * 2. Have a compatible global store (`pnpm root -g` points to an existing dir)
472
- *
473
- * This defends against environments with multiple pnpm major versions
474
- * (e.g., standalone v8 + nvm v10) where the wrong one would fail with
475
- * ERR_PNPM_UNEXPECTED_STORE on `pnpm add -g`.
476
- *
477
- * Honors `NEUROLINK_PNPM_PATH` as an escape hatch.
478
- */
479
- function resolveFullPnpmPath() {
480
- const candidates = [];
481
- // 1. User override
482
- if (process.env.NEUROLINK_PNPM_PATH) {
483
- candidates.push(process.env.NEUROLINK_PNPM_PATH);
484
- }
485
- // 2. PNPM_HOME (pnpm's own env variable)
486
- if (process.env.PNPM_HOME) {
487
- candidates.push(join(process.env.PNPM_HOME, "pnpm"));
488
- }
489
- // 3. `which pnpm` — whatever is on PATH
490
- try {
491
- const { execFileSync } = _require("node:child_process");
492
- const whichOut = execFileSync("which", ["pnpm"], {
493
- encoding: "utf8",
494
- timeout: 5_000,
495
- stdio: ["ignore", "pipe", "ignore"],
496
- }).trim();
497
- if (whichOut) {
498
- candidates.push(whichOut);
499
- }
500
- }
501
- catch {
502
- // ignore
503
- }
504
- // 4. Common standalone installer locations
505
- candidates.push(join(homedir(), ".local", "share", "pnpm", "pnpm"));
506
- candidates.push(join(homedir(), "Library", "pnpm", "pnpm"));
507
- // Dedupe while preserving order
508
- const seen = new Set();
509
- const unique = candidates.filter((p) => {
510
- if (!p || seen.has(p)) {
511
- return false;
512
- }
513
- seen.add(p);
514
- return true;
515
- });
516
- // Probe each candidate: must pass --version AND have a compatible global store
517
- const tried = unique.map((path) => {
518
- const version = probeBinVersion(path);
519
- const working = version !== undefined;
520
- const globalStoreOk = working ? canInstallGlobally(path) : false;
521
- return { path, version, working, globalStoreOk };
522
- });
523
- // Prefer a candidate that can actually install globally
524
- const fullyWorking = tried.find((r) => r.working && r.globalStoreOk);
525
- if (fullyWorking) {
526
- return {
527
- bin: fullyWorking.path,
528
- resolved: true,
529
- version: fullyWorking.version,
530
- tried,
531
- };
532
- }
533
- // Fall back to any candidate that at least responds to --version
534
- // (better than nothing — the install may still fail, but will be
535
- // caught and suppressed by the caller)
536
- const anyWorking = tried.find((r) => r.working);
537
- if (anyWorking) {
538
- return {
539
- bin: anyWorking.path,
540
- resolved: true,
541
- version: anyWorking.version,
542
- tried,
543
- };
544
- }
545
- return { bin: "pnpm", resolved: false, tried };
546
- }
547
477
  function spawnFailOpenGuard(host, port, parentPid) {
548
478
  // The guard runs the same version as this process, so process.argv[1]
549
479
  // (the currently-running script) is correct here — no stale-path risk.
@@ -587,6 +517,52 @@ function spawnFailOpenGuard(host, port, parentPid) {
587
517
  closeSync(logFd); // parent closes its copy; child keeps the fd
588
518
  }
589
519
  }
520
+ function spawnProxyUpdater(host, port, parentPid) {
521
+ if (!isProxyAutoUpdateEnabled()) {
522
+ logger.always("[proxy] automatic updates disabled by environment");
523
+ return undefined;
524
+ }
525
+ const entryScript = process.argv[1];
526
+ if (!entryScript) {
527
+ logger.always("[proxy] updater disabled: CLI entry script is unavailable");
528
+ return undefined;
529
+ }
530
+ const args = [
531
+ entryScript,
532
+ "proxy",
533
+ "guard",
534
+ "--host",
535
+ host,
536
+ "--port",
537
+ String(port),
538
+ "--parent-pid",
539
+ String(parentPid),
540
+ "--updater-only",
541
+ "--quiet",
542
+ ];
543
+ const { openSync, closeSync, mkdirSync, existsSync } = _require("fs");
544
+ const logsDir = join(homedir(), ".neurolink", "logs");
545
+ if (!existsSync(logsDir)) {
546
+ mkdirSync(logsDir, { recursive: true });
547
+ }
548
+ const logFd = openSync(join(logsDir, "proxy-updater.log"), "a");
549
+ try {
550
+ const child = spawn(process.execPath, args, {
551
+ detached: true,
552
+ stdio: ["ignore", logFd, logFd],
553
+ env: process.env,
554
+ });
555
+ child.unref();
556
+ return child.pid;
557
+ }
558
+ catch (error) {
559
+ logger.always(`[proxy] updater failed to start: ${error instanceof Error ? error.message : String(error)}`);
560
+ return undefined;
561
+ }
562
+ finally {
563
+ closeSync(logFd);
564
+ }
565
+ }
590
566
  async function runProxyTelemetryManager(command) {
591
567
  const { existsSync } = await import("fs");
592
568
  if (!existsSync(PROXY_TELEMETRY_SCRIPT_PATH)) {
@@ -826,26 +802,100 @@ async function resolveBootPrimaryAccountKey(primaryEmail) {
826
802
  }
827
803
  return key;
828
804
  }
829
- async function createProxyStartApp(params) {
805
+ export async function createProxyStartApp(params) {
830
806
  const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
831
807
  const { createOpenAIProxyRoutes } = await import("../../lib/server/routes/openaiProxyRoutes.js");
808
+ const { logBodyCapture, logRequest } = await import("../../lib/proxy/requestLogger.js");
809
+ const { recordFinalError } = await import("../../lib/proxy/usageStats.js");
832
810
  const { Hono } = await import("hono");
833
811
  const app = new Hono();
834
812
  const readiness = createProxyReadinessState();
835
- app.onError((err, c) => {
813
+ const requestMetadata = new WeakMap();
814
+ const recordRuntimeError = async (metadata, status, errorType, errorMessage, clientMessage = errorMessage, clientErrorType = errorType) => {
815
+ recordFinalError(status);
816
+ await Promise.all([
817
+ logRequest({
818
+ timestamp: new Date().toISOString(),
819
+ requestId: metadata.requestId,
820
+ method: metadata.method,
821
+ path: metadata.path,
822
+ model: metadata.model,
823
+ stream: metadata.stream,
824
+ toolCount: metadata.toolCount,
825
+ account: "",
826
+ accountType: "proxy-runtime",
827
+ responseStatus: status,
828
+ responseTimeMs: Date.now() - metadata.startedAt,
829
+ errorType,
830
+ errorMessage,
831
+ }),
832
+ logBodyCapture({
833
+ timestamp: new Date().toISOString(),
834
+ requestId: metadata.requestId,
835
+ model: metadata.model,
836
+ stream: metadata.stream,
837
+ phase: "client_response",
838
+ headers: { "content-type": "application/json" },
839
+ body: {
840
+ type: "error",
841
+ error: { type: clientErrorType, message: clientMessage },
842
+ },
843
+ contentType: "application/json",
844
+ responseStatus: status,
845
+ durationMs: Date.now() - metadata.startedAt,
846
+ }),
847
+ ]);
848
+ };
849
+ app.onError(async (err, c) => {
836
850
  const errMsg = err instanceof Error ? err.message : String(err);
837
851
  logger.always(`[proxy] unhandled error: ${errMsg}`);
838
852
  if (err instanceof Error && err.stack) {
839
- logger.debug(`[proxy] stack: ${err.stack}`);
840
- }
853
+ logger.always(`[proxy] unhandled stack: ${err.stack}`);
854
+ }
855
+ const metadata = requestMetadata.get(c.req.raw) ?? {
856
+ requestId: crypto.randomUUID(),
857
+ method: c.req.method,
858
+ path: c.req.path,
859
+ startedAt: Date.now(),
860
+ model: "-",
861
+ stream: false,
862
+ toolCount: 0,
863
+ };
864
+ await recordRuntimeError(metadata, 502, "unhandled_proxy_error", errMsg, "Proxy internal error", "api_error");
865
+ requestMetadata.delete(c.req.raw);
841
866
  return c.json({
842
867
  type: "error",
843
868
  error: {
844
869
  type: "api_error",
845
- message: `Proxy internal error: ${errMsg}`,
870
+ message: "Proxy internal error",
846
871
  },
847
872
  }, 502);
848
873
  });
874
+ app.use("/v1/*", async (c, next) => {
875
+ const metadata = {
876
+ requestId: crypto.randomUUID(),
877
+ method: c.req.method,
878
+ path: c.req.path,
879
+ startedAt: Date.now(),
880
+ model: "-",
881
+ stream: false,
882
+ toolCount: 0,
883
+ };
884
+ requestMetadata.set(c.req.raw, metadata);
885
+ const finishActivity = beginProxyRequest();
886
+ const finish = () => {
887
+ finishActivity();
888
+ requestMetadata.delete(c.req.raw);
889
+ };
890
+ try {
891
+ await next();
892
+ c.res = trackProxyResponse(c.res, finish);
893
+ }
894
+ catch (error) {
895
+ finishActivity();
896
+ throw error;
897
+ }
898
+ });
849
899
  const routeGroup = createClaudeProxyRoutes(params.modelRouter, "", params.strategy, params.passthrough, params.primaryAccountKey, params.accountAllowlist);
850
900
  const openaiRouteGroup = createOpenAIProxyRoutes(params.modelRouter, "", params.port);
851
901
  const allProxyRoutes = [...routeGroup.routes, ...openaiRouteGroup.routes];
@@ -861,6 +911,10 @@ async function createProxyStartApp(params) {
861
911
  body = rawBody ? JSON.parse(rawBody) : emptyBody;
862
912
  }
863
913
  catch {
914
+ const metadata = requestMetadata.get(c.req.raw);
915
+ if (metadata) {
916
+ await recordRuntimeError(metadata, 400, "invalid_request_error", "Request body must be valid JSON");
917
+ }
864
918
  return c.json({
865
919
  type: "error",
866
920
  error: {
@@ -878,9 +932,15 @@ async function createProxyStartApp(params) {
878
932
  const toolCount = Array.isArray(bodyRec?.tools)
879
933
  ? bodyRec.tools.length
880
934
  : 0;
935
+ const metadata = requestMetadata.get(c.req.raw);
936
+ if (metadata) {
937
+ metadata.model = String(model);
938
+ metadata.stream = stream === "stream";
939
+ metadata.toolCount = toolCount;
940
+ }
881
941
  logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${model} ${stream} tools=${toolCount}`);
882
942
  const ctx = {
883
- requestId: crypto.randomUUID(),
943
+ requestId: metadata?.requestId ?? crypto.randomUUID(),
884
944
  method: c.req.method,
885
945
  path: c.req.path,
886
946
  headers: Object.fromEntries(c.req.raw.headers.entries()),
@@ -973,6 +1033,7 @@ async function createProxyStartApp(params) {
973
1033
  const { getStats } = await import("../../lib/proxy/usageStats.js");
974
1034
  const { loadAccountCooldowns } = await import("../../lib/proxy/accountCooldown.js");
975
1035
  const stats = getStats();
1036
+ const runtimeState = loadProxyState();
976
1037
  const cooldowns = await loadAccountCooldowns();
977
1038
  const storedAccountKeys = new Set();
978
1039
  try {
@@ -1031,6 +1092,20 @@ async function createProxyStartApp(params) {
1031
1092
  }),
1032
1093
  primaryAccount,
1033
1094
  },
1095
+ activity: (() => {
1096
+ const activity = getProxyActivitySnapshot();
1097
+ return {
1098
+ activeRequests: activity.activeRequests,
1099
+ lastActivityAt: activity.lastActivityAt?.toISOString() ?? null,
1100
+ };
1101
+ })(),
1102
+ autoUpdate: {
1103
+ enabled: isProxyAutoUpdateEnabled(),
1104
+ updaterPid: runtimeState?.updaterPid ?? null,
1105
+ updaterRunning: runtimeState?.updaterPid
1106
+ ? isProcessRunning(runtimeState.updaterPid)
1107
+ : false,
1108
+ },
1034
1109
  config: params.proxyConfig
1035
1110
  ? {
1036
1111
  hasRouting: !!params.proxyConfig.routing,
@@ -1354,6 +1429,9 @@ async function startProxyRuntime(params) {
1354
1429
  port: params.port,
1355
1430
  });
1356
1431
  markProxyReady(params.readiness);
1432
+ const updaterPid = managedByLaunchd && !params.argv.dev
1433
+ ? spawnProxyUpdater(readinessHost, params.port, process.pid)
1434
+ : undefined;
1357
1435
  const fallbackChain = params.proxyConfig?.routing?.fallbackChain?.map((entry) => ({
1358
1436
  provider: entry.provider,
1359
1437
  model: entry.model,
@@ -1376,6 +1454,7 @@ async function startProxyRuntime(params) {
1376
1454
  ? [...params.accountAllowlist]
1377
1455
  : undefined,
1378
1456
  guardPid,
1457
+ updaterPid,
1379
1458
  managedBy: managedByLaunchd ? "launchd" : "manual",
1380
1459
  passthrough: params.passthrough,
1381
1460
  });
@@ -1643,6 +1722,9 @@ export const proxyStatusCommand = {
1643
1722
  envFile: null,
1644
1723
  fallbackChain: null,
1645
1724
  accountAllowlist: null,
1725
+ autoUpdateEnabled: isProxyAutoUpdateEnabled(),
1726
+ updaterPid: null,
1727
+ updaterRunning: false,
1646
1728
  };
1647
1729
  if (state && isProcessRunning(state.pid)) {
1648
1730
  status.running = true;
@@ -1657,6 +1739,10 @@ export const proxyStatusCommand = {
1657
1739
  status.envFile = state.envFile ?? null;
1658
1740
  status.fallbackChain = state.fallbackChain ?? null;
1659
1741
  status.accountAllowlist = state.accountAllowlist ?? null;
1742
+ status.updaterPid = state.updaterPid ?? null;
1743
+ status.updaterRunning = state.updaterPid
1744
+ ? isProcessRunning(state.updaterPid)
1745
+ : false;
1660
1746
  }
1661
1747
  // Fetch live stats before rendering (JSON or text)
1662
1748
  let liveStats = null;
@@ -1689,6 +1775,7 @@ export const proxyStatusCommand = {
1689
1775
  logger.always(` ${chalk.bold("Mode:")} ${chalk.cyan(status.mode ?? "full")}`);
1690
1776
  logger.always(` ${chalk.bold("Started:")} ${chalk.cyan(status.startTime)}`);
1691
1777
  logger.always(` ${chalk.bold("Uptime:")} ${chalk.cyan(formatUptime(status.uptime ?? 0))}`);
1778
+ logger.always(` ${chalk.bold("Auto-update:")} ${status.autoUpdateEnabled ? chalk.green(status.updaterRunning ? `enabled (PID ${status.updaterPid})` : "enabled (worker unavailable)") : chalk.yellow("disabled")}`);
1692
1779
  if (status.envFile) {
1693
1780
  logger.always(` ${chalk.bold("Env File:")} ${chalk.cyan(status.envFile)}`);
1694
1781
  }
@@ -1836,6 +1923,11 @@ export const proxyGuardCommand = {
1836
1923
  type: "number",
1837
1924
  alias: "pollIntervalMs",
1838
1925
  default: 1_000,
1926
+ })
1927
+ .option("updater-only", {
1928
+ type: "boolean",
1929
+ alias: "updaterOnly",
1930
+ default: false,
1839
1931
  })
1840
1932
  .option("quiet", {
1841
1933
  type: "boolean",
@@ -1852,11 +1944,12 @@ export const proxyGuardCommand = {
1852
1944
  : 0;
1853
1945
  const failureThreshold = Math.max(1, Number(argv.failureThreshold ?? 5));
1854
1946
  const pollIntervalMs = Math.max(250, Number(argv.pollIntervalMs ?? 1_000));
1947
+ const updaterOnly = argv.updaterOnly === true;
1855
1948
  if (!Number.isFinite(parentPid) || parentPid <= 0) {
1856
1949
  return;
1857
1950
  }
1858
- // Automatic package mutation is disabled by default. Operators must opt in
1859
- // explicitly, and launchd-managed proxy processes do not spawn this guard.
1951
+ // Package mutation belongs to the dedicated launchd updater worker. The
1952
+ // foreground fail-open guard remains cleanup-only.
1860
1953
  const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
1861
1954
  const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
1862
1955
  const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
@@ -1874,7 +1967,8 @@ export const proxyGuardCommand = {
1874
1967
  }
1875
1968
  // Auto-update only works on macOS with launchd. On other platforms,
1876
1969
  // there's no restart mechanism, so skip the update loop entirely.
1877
- const canAutoUpdate = isProxyAutoUpdateEnabled() &&
1970
+ const canAutoUpdate = updaterOnly &&
1971
+ isProxyAutoUpdateEnabled() &&
1878
1972
  process.platform === "darwin" &&
1879
1973
  (await isLaunchdManaging());
1880
1974
  let guardStopping = false;
@@ -1901,7 +1995,6 @@ export const proxyGuardCommand = {
1901
1995
  try {
1902
1996
  // Lazy-load update modules so they're only imported at check time
1903
1997
  const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
1904
- const { checkTrafficQuiet } = await import("../../lib/proxy/quietDetector.js");
1905
1998
  const { recordCheck, isVersionSuppressed, suppressVersion, recordSuccessfulUpdate, } = await import("../../lib/proxy/updateState.js");
1906
1999
  // 1. Check for update
1907
2000
  const result = await checkForUpdate(runningVersion);
@@ -1913,141 +2006,111 @@ export const proxyGuardCommand = {
1913
2006
  logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
1914
2007
  return;
1915
2008
  }
1916
- logger.always(`[guard] update available: ${runningVersion} → ${result.latestVersion}`);
1917
- // 2. Best-effort quiet wait try for a brief window, but proceed
1918
- // regardless. The install (pnpm add -g) is non-disruptive; only the
1919
- // restart causes a ~1-3s blip. Blocking updates for hours/days because
1920
- // traffic never goes silent is worse than a brief interruption.
1921
- const maxQuietWaitMs = 5 * 60 * 1000; // 5 minutes max, then proceed
2009
+ logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
2010
+ // 2. Wait for an exact runtime-idle window. Never force an update while
2011
+ // a request or stream is active; the next worker cycle can try again.
1922
2012
  const quietPollMs = 10_000; // check every 10s
1923
- const quietStart = Date.now();
1924
- while (Date.now() - quietStart < maxQuietWaitMs) {
2013
+ while (!guardStopping) {
1925
2014
  if (getProcessStatus(parentPid) === "not_running") {
1926
- logger.always(`[guard] parent process died during quiet-wait, aborting update`);
2015
+ logger.always(`[updater] parent process died while waiting for idle traffic`);
1927
2016
  return;
1928
2017
  }
1929
- const quietStatus = checkTrafficQuiet(QUIET_THRESHOLD_MS);
1930
- if (quietStatus.isQuiet) {
1931
- logger.always(`[guard] traffic quiet, proceeding with update`);
2018
+ const activity = await getProxyRuntimeActivity(host, port);
2019
+ if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2020
+ logger.always(`[updater] traffic idle, proceeding with update`);
1932
2021
  break;
1933
2022
  }
1934
- logger.debug(`[guard] traffic active (last activity ${Math.round(quietStatus.silenceDurationMs / 1000)}s ago), waiting...`);
2023
+ logger.debug(`[updater] waiting for idle traffic (${activity?.activeRequests ?? "unknown"} active requests)`);
1935
2024
  await new Promise((r) => setTimeout(r, quietPollMs));
1936
2025
  }
1937
- // Proceed with install regardless — don't block updates indefinitely.
1938
- // The install itself (pnpm add -g) doesn't affect the running process.
1939
- // Only the restart afterwards causes a brief interruption.
1940
- const finalQuiet = checkTrafficQuiet(QUIET_THRESHOLD_MS);
1941
- if (!finalQuiet.isQuiet) {
1942
- logger.always(`[guard] traffic still active after ${Math.round(maxQuietWaitMs / 1000)}s wait, proceeding with update anyway (restart will briefly interrupt in-flight requests)`);
2026
+ if (guardStopping) {
2027
+ return;
1943
2028
  }
1944
2029
  // 3. Install update (validate version string before passing to shell)
1945
2030
  if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
1946
2031
  logger.always(`[guard] WARNING: invalid version format "${result.latestVersion}", skipping`);
1947
2032
  return;
1948
2033
  }
1949
- // Resolve pnpm to a deterministic path, validating that it actually
1950
- // runs (some environments have broken shims on PATH).
1951
- const pnpmResolution = resolveFullPnpmPath();
1952
- // Log the full candidate list so operators can see why a particular
1953
- // pnpm was chosen (or why none worked).
1954
- logger.always(`[guard] pnpm candidates: ${pnpmResolution.tried
1955
- .map((c) => `${c.path}(${c.working ? `v${c.version}` : "BROKEN"})`)
2034
+ const installerResolution = resolveGlobalInstaller({
2035
+ entryScript: process.argv[1],
2036
+ });
2037
+ logger.always(`[updater] package-manager candidates: ${installerResolution.tried
2038
+ .map((candidate) => `${candidate.kind}:${candidate.bin}(${candidate.installable ? `v${candidate.version}` : (candidate.reason ?? "unusable")})`)
1956
2039
  .join(", ")}`);
1957
- if (!pnpmResolution.resolved) {
1958
- // Environmental problem, not version-specific — skip this cycle
1959
- // without suppressing the version (so we retry on the next tick
1960
- // once the user fixes pnpm).
1961
- logger.always(`[guard] WARNING: no working pnpm found; skipping update cycle. Install pnpm or set NEUROLINK_PNPM_PATH.`);
2040
+ const installer = installerResolution.installer;
2041
+ if (!installer) {
2042
+ logger.always(`[updater] WARNING: no package manager has a writable global root and executable directory; skipping this cycle`);
1962
2043
  return;
1963
2044
  }
1964
- logger.always(`[guard] traffic quiet, installing @juspay/neurolink@${result.latestVersion} via ${pnpmResolution.bin} (pnpm v${pnpmResolution.version})...`);
2045
+ logger.always(`[updater] installing @juspay/neurolink@${result.latestVersion} via ${installer.kind} ${installer.bin} (v${installer.version})`);
1965
2046
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
1966
2047
  return;
1967
2048
  }
1968
2049
  const { execFileSync } = await import("node:child_process");
1969
2050
  try {
1970
- execFileSync(pnpmResolution.bin, ["add", "-g", `@juspay/neurolink@${result.latestVersion}`], {
2051
+ execFileSync(installer.bin, getGlobalInstallArgs(installer.kind, `@juspay/neurolink@${result.latestVersion}`), {
1971
2052
  timeout: 120_000,
1972
2053
  stdio: "pipe",
1973
2054
  });
1974
2055
  }
1975
2056
  catch (installErr) {
1976
- // Capture stderr for actionable diagnostics
1977
- const stderr = installErr &&
1978
- typeof installErr === "object" &&
1979
- "stderr" in installErr
1980
- ? String(installErr.stderr).slice(0, 500)
1981
- : "";
1982
- const msg = installErr instanceof Error
1983
- ? installErr.message
1984
- : String(installErr);
1985
- logger.always(`[guard] WARNING: pnpm install failed: ${msg}${stderr ? `\n stderr: ${stderr}` : ""}`);
1986
- suppressVersion(result.latestVersion, `install_failed: ${msg.slice(0, 200)}${stderr ? ` | stderr: ${stderr.slice(0, 200)}` : ""}`);
2057
+ const detail = describeInstallFailure(installErr);
2058
+ logger.always(`[updater] WARNING: global install failed:\n${detail}`);
1987
2059
  return;
1988
2060
  }
1989
- // 4. Rewrite the launchd plist so it picks up the (possibly new)
1990
- // stable bin path, then restart via launchctl.
2061
+ // 4. Refresh and validate the stable trampoline. The plist already
2062
+ // points at this path, so it must not be unloaded or rewritten here.
1991
2063
  try {
1992
- const { writeFileSync, existsSync: fsExists, mkdirSync: fsMkdir, } = await import("fs");
1993
- if (!fsExists(PLIST_DIR)) {
1994
- fsMkdir(PLIST_DIR, { recursive: true });
1995
- }
1996
- // Rewrite the trampoline and plist so the restarted service
1997
- // resolves the newly installed binary via PATH.
1998
2064
  writeTrampoline();
1999
2065
  // Validate the trampoline actually resolves to the NEW version
2000
2066
  // before asking launchd to restart. If the install somehow left
2001
2067
  // PATH still pointing at the old version, don't kickstart.
2002
2068
  const probed = probeBinVersion(TRAMPOLINE_PATH);
2003
2069
  if (!probed) {
2004
- logger.always(`[guard] WARNING: trampoline does not resolve to a working neurolink after install; skipping restart.`);
2070
+ logger.always(`[updater] WARNING: trampoline does not resolve to a working neurolink after install; skipping restart`);
2005
2071
  suppressVersion(result.latestVersion, `trampoline_broken_after_install: ${TRAMPOLINE_PATH} --version failed`);
2006
2072
  return;
2007
2073
  }
2008
2074
  if (probed !== result.latestVersion) {
2009
- // The trampoline resolves to a DIFFERENT version than what we
2010
- // just installed. This means `pnpm add -g` installed into a
2011
- // store that PATH doesn't reach (store mismatch), or PATH still
2012
- // shadows with an older shim. Restarting would run the wrong
2013
- // version — abort.
2014
- logger.always(`[guard] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}.`);
2015
- logger.always(`[guard] pnpm used: ${pnpmResolution.bin} (v${pnpmResolution.version})`);
2016
- logger.always(`[guard] This usually means pnpm's global store doesn't match the PATH-visible neurolink.`);
2017
- logger.always(`[guard] Fix: run 'pnpm add -g @juspay/neurolink' with the SAME pnpm whose bin dir is on PATH.`);
2018
- suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} pnpm=${pnpmResolution.bin}(v${pnpmResolution.version})`);
2075
+ logger.always(`[updater] ABORT: trampoline resolves to v${probed} but installed v${result.latestVersion}`);
2076
+ logger.always(`[updater] installer used: ${installer.kind} ${installer.bin} (v${installer.version})`);
2077
+ suppressVersion(result.latestVersion, `version_mismatch: trampoline=${probed} expected=${result.latestVersion} installer=${installer.kind}:${installer.bin}(v${installer.version})`);
2019
2078
  return;
2020
2079
  }
2021
- const existingArgs = parseExistingPlistArgs();
2022
- const updatedPlist = buildPlist(port, host, existingArgs.envFile, existingArgs.configFile);
2023
- writeFileSync(PLIST_PATH, updatedPlist, "utf-8");
2024
- logger.always(`[guard] trampoline (resolves to v${probed}) + plist rewritten at ${PLIST_PATH}`);
2080
+ logger.always(`[updater] trampoline validated at v${probed}`);
2025
2081
  }
2026
- catch (plistErr) {
2027
- logger.always(`[guard] WARNING: failed to rewrite plist (restart may use stale path): ${plistErr instanceof Error ? plistErr.message : String(plistErr)}`);
2028
- // Continue with restart anyway — the stable bin symlink may still be correct
2082
+ catch (trampolineError) {
2083
+ logger.always(`[updater] WARNING: failed to refresh trampoline; refusing restart: ${trampolineError instanceof Error
2084
+ ? trampolineError.message
2085
+ : String(trampolineError)}`);
2086
+ return;
2029
2087
  }
2030
2088
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2031
2089
  return;
2032
2090
  }
2091
+ // Installation can overlap new traffic. Re-establish a full idle window
2092
+ // before restart so no accepted request or long stream is interrupted.
2093
+ while (!guardStopping) {
2094
+ if (getProcessStatus(parentPid) === "not_running") {
2095
+ return;
2096
+ }
2097
+ const activity = await getProxyRuntimeActivity(host, port);
2098
+ if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2099
+ break;
2100
+ }
2101
+ logger.debug(`[updater] update installed; deferring restart (${activity?.activeRequests ?? "unknown"} active requests)`);
2102
+ await sleep(10_000);
2103
+ }
2104
+ if (guardStopping) {
2105
+ return;
2106
+ }
2033
2107
  // Signal the health loop to not exit when it detects
2034
2108
  // the parent PID is gone — we're intentionally restarting.
2035
2109
  updateRestartInProgress = true;
2036
- logger.always(`[guard] restarting proxy via launchctl bootout/bootstrap...`);
2110
+ logger.always(`[updater] restarting proxy via launchctl kickstart`);
2037
2111
  const uid = process.getuid?.() ?? 501;
2038
2112
  try {
2039
- // bootout unloads the in-memory job definition. This is required
2040
- // because a forced kickstart reuses the cached plist and ignores any
2041
- // on-disk changes (like the trampoline rewrite above).
2042
- try {
2043
- execFileSync("launchctl", ["bootout", `gui/${uid}/${PLIST_LABEL}`], { timeout: 10_000, stdio: "pipe" });
2044
- }
2045
- catch {
2046
- // Job may not be loaded (first install, or already unloaded)
2047
- }
2048
- // bootstrap loads the plist fresh from disk, picking up the
2049
- // new trampoline-based ProgramArguments.
2050
- execFileSync("launchctl", ["bootstrap", `gui/${uid}`, PLIST_PATH], {
2113
+ execFileSync("launchctl", ["kickstart", "-k", `gui/${uid}/${PLIST_LABEL}`], {
2051
2114
  timeout: 10_000,
2052
2115
  stdio: "pipe",
2053
2116
  });
@@ -2057,8 +2120,7 @@ export const proxyGuardCommand = {
2057
2120
  const msg = restartErr instanceof Error
2058
2121
  ? restartErr.message
2059
2122
  : String(restartErr);
2060
- logger.always(`[guard] WARNING: launchctl bootstrap failed: ${msg}`);
2061
- suppressVersion(result.latestVersion, `restart_failed: ${msg.slice(0, 200)}`);
2123
+ logger.always(`[updater] WARNING: launchctl kickstart failed: ${msg}`);
2062
2124
  return;
2063
2125
  }
2064
2126
  // 5. Wait for healthy restart
@@ -2083,19 +2145,19 @@ export const proxyGuardCommand = {
2083
2145
  }
2084
2146
  }
2085
2147
  if (healthy) {
2086
- logger.always(`[guard] update successful: now running ${result.latestVersion}`);
2148
+ logger.always(`[updater] update successful: now running ${result.latestVersion}`);
2087
2149
  recordSuccessfulUpdate(result.latestVersion);
2088
- // The new proxy will spawn its own guard. Exit this one.
2150
+ // The replacement proxy starts a worker running the new version.
2089
2151
  process.exit(0);
2090
2152
  }
2091
2153
  else {
2092
- logger.always(`[guard] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
2154
+ logger.always(`[updater] WARNING: proxy unhealthy after update to ${result.latestVersion}`);
2093
2155
  suppressVersion(result.latestVersion, "unhealthy_after_restart");
2094
2156
  updateRestartInProgress = false;
2095
2157
  }
2096
2158
  }
2097
2159
  catch (err) {
2098
- logger.always(`[guard] update check error: ${err instanceof Error ? err.message : String(err)}`);
2160
+ logger.always(`[updater] update check error: ${err instanceof Error ? err.message : String(err)}`);
2099
2161
  }
2100
2162
  finally {
2101
2163
  updateInProgress = false;
@@ -2127,7 +2189,9 @@ export const proxyGuardCommand = {
2127
2189
  }
2128
2190
  break;
2129
2191
  }
2130
- if (!healthy && consecutiveUnhealthy >= failureThreshold) {
2192
+ if (!updateRestartInProgress &&
2193
+ !healthy &&
2194
+ consecutiveUnhealthy >= failureThreshold) {
2131
2195
  // A detached guard cannot safely decide that a live process should be
2132
2196
  // replaced. Leave recovery to the foreground operator or launchd.
2133
2197
  if (!argv.quiet) {
@@ -2144,6 +2208,9 @@ export const proxyGuardCommand = {
2144
2208
  parentStatus = getProcessStatus(parentPid);
2145
2209
  }
2146
2210
  stopUpdateChecks();
2211
+ if (updaterOnly) {
2212
+ return;
2213
+ }
2147
2214
  const guardHost = host === "0.0.0.0" ? "localhost" : host;
2148
2215
  const expectedBaseUrl = `http://${guardHost}:${port}`;
2149
2216
  // The parent is confirmed gone and no replacement is healthy. Foreground
@@ -2335,36 +2402,6 @@ function buildLaunchdPath() {
2335
2402
  }
2336
2403
  return [...segments].join(":");
2337
2404
  }
2338
- /**
2339
- * Parse the existing launchd plist to extract --env-file and --config values.
2340
- * Used by the auto-updater to rewrite the plist with the same configuration.
2341
- */
2342
- function parseExistingPlistArgs() {
2343
- try {
2344
- const { readFileSync, existsSync: fsExists } = _require("fs");
2345
- if (!fsExists(PLIST_PATH)) {
2346
- return {};
2347
- }
2348
- const xml = readFileSync(PLIST_PATH, "utf-8");
2349
- // Extract --env-file value: <string>--env-file</string>\n <string>VALUE</string>
2350
- const envMatch = xml.match(/<string>--env-file<\/string>\s*<string>([^<]+)<\/string>/);
2351
- const configMatch = xml.match(/<string>--config<\/string>\s*<string>([^<]+)<\/string>/);
2352
- // Unescape XML entities so buildPlist() doesn't double-escape them.
2353
- const unescapeXml = (value) => value
2354
- ?.replace(/&apos;/g, "'")
2355
- .replace(/&quot;/g, '"')
2356
- .replace(/&gt;/g, ">")
2357
- .replace(/&lt;/g, "<")
2358
- .replace(/&amp;/g, "&");
2359
- return {
2360
- envFile: unescapeXml(envMatch?.[1]),
2361
- configFile: unescapeXml(configMatch?.[1]),
2362
- };
2363
- }
2364
- catch {
2365
- return {};
2366
- }
2367
- }
2368
2405
  function buildPlist(port, host, envFile, configFile) {
2369
2406
  // The plist invokes the trampoline script (a tiny shell wrapper at
2370
2407
  // ~/.neurolink/bin/neurolink-proxy) which re-resolves the real