@juspay/neurolink 12.54.0 → 12.54.2

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/CHANGELOG.md CHANGED
@@ -1,9 +1,8 @@
1
- ## [12.54.0](https://github.com/juspay/neurolink/compare/v12.53.0...v12.54.0) (2026-10-12)
1
+ ## [12.54.2](https://github.com/juspay/neurolink/compare/v12.54.1...v12.54.2) (2026-10-12)
2
2
 
3
- ### Features
3
+ ### Bug Fixes
4
4
 
5
- - **(examples):** rebuild voice bench as a React app ([e8ee2c3](https://github.com/juspay/neurolink/commit/e8ee2c399a05aa15d4fd9273c58a1d4ad081744f))
6
- - **(observability):** keep conversation, tool arguments and turn outcome on Vertex Claude traces ([0aa412c](https://github.com/juspay/neurolink/commit/0aa412c49634f3fd86f92179a0b45b7797877707))
5
+ - **(proxy):** prune superseded package builds after update and on updater start ([a9915f6](https://github.com/juspay/neurolink/commit/a9915f633825c07715cefdeb0d7587affe473f5b))
7
6
 
8
7
  ## [11.2.3](https://github.com/juspay/neurolink/compare/v11.2.2...v11.2.3) (2026-08-19)
9
8
 
@@ -24,6 +24,7 @@ import { resolveProxyStatusAccountIdentity } from "../../proxy/codexAccountUsage
24
24
  import { beginProxyRequest, consumeInternalProxyRequest, releaseProxyRequestAccounting, withProxyFinalLogOwnership, getProxyActivitySnapshot, observeProxyFinalLog, takeProxyResponseObservers, trackProxyResponse, } from "../../proxy/proxyActivity.js";
25
25
  import { configureProxyLifecycleLogger, flushProxyLifecycleEvents, persistProxyLifecycleAcceptance, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
26
26
  import { describeInstallFailure, assertProxyServiceInstallIdle, probeProxyLaunchdPresence, inspectProxyPackage, isProxyServiceEnvironmentKey, parseProxyServiceInstallSettings, installStagedProxyPackage, prepareProxyPackageUpgrade, readProxyPackageSelection, resolveProxyWorkerPackage, selectProxyPackage, writeProxyPackageLauncher, isTransientInstallFailure, resolveGlobalInstaller, validateInstalledVersion, } from "../../proxy/globalInstaller.js";
27
+ import { pruneProxyPackages } from "../../proxy/packagePrune.js";
27
28
  import { startUpdaterWorkerSupervisor } from "../../proxy/updaterSupervisor.js";
28
29
  import { openProxyWorkerLog } from "../../proxy/workerLog.js";
29
30
  import { startRollingProxyServer } from "../../proxy/rollingProxyServer.js";
@@ -4094,13 +4095,55 @@ export const proxyGuardCommand = {
4094
4095
  runtimeUpdaterPid: runtime?.updaterPid,
4095
4096
  });
4096
4097
  };
4098
+ // Each release installs a full dependency tree into its own directory and
4099
+ // nothing else ever removes one, so the store has to be trimmed here: on
4100
+ // updater start for the backlog, and after each confirmed update.
4097
4101
  let updateInProgress = false;
4098
4102
  let updateRestartInProgress = false;
4103
+ let packagePruneInFlight;
4104
+ const prunePackageStore = (trigger) => {
4105
+ packagePruneInFlight ??= (async () => {
4106
+ // A stale updater must not mutate package state a newer owner relies on.
4107
+ // An update that reuses an existing unselected build would also race the
4108
+ // rename; the post-update pass runs inside the update and is safe.
4109
+ if (!isCurrentUpdateOwner() ||
4110
+ (updateInProgress && trigger !== "after update")) {
4111
+ return;
4112
+ }
4113
+ try {
4114
+ const outcome = await pruneProxyPackages({
4115
+ packagesDir: PROXY_PACKAGES_DIR,
4116
+ currentEntryScript: process.argv[1],
4117
+ isCurrentOwner: isCurrentUpdateOwner,
4118
+ });
4119
+ if (outcome.skipped) {
4120
+ logger.always(`[updater] package store prune skipped (${trigger}): ${outcome.skipped}`);
4121
+ }
4122
+ else if (outcome.removed.length > 0) {
4123
+ logger.always(`[updater] package store prune (${trigger}): removed ${outcome.removed.length} old build(s); retained ${outcome.retained
4124
+ .map((build) => `${build.name} [${build.reason}]`)
4125
+ .join(", ")}`);
4126
+ }
4127
+ for (const failure of outcome.failed) {
4128
+ logger.always(`[updater] WARNING: could not remove old build ${failure.name}: ${failure.message}`);
4129
+ }
4130
+ }
4131
+ catch (error) {
4132
+ logger.always(`[updater] WARNING: package store prune failed (${trigger}): ${error instanceof Error ? error.message : String(error)}`);
4133
+ }
4134
+ })().finally(() => {
4135
+ packagePruneInFlight = undefined;
4136
+ });
4137
+ return packagePruneInFlight;
4138
+ };
4099
4139
  const runUpdateCheck = async () => {
4100
4140
  if (updateInProgress || !isCurrentUpdateOwner()) {
4101
4141
  return;
4102
4142
  }
4103
4143
  updateInProgress = true;
4144
+ // A pass already under way works from a snapshot of the store and could
4145
+ // rename a build this update is about to reuse.
4146
+ await packagePruneInFlight?.catch(() => undefined);
4104
4147
  let updateVersion = runningVersion;
4105
4148
  let drainActive = false;
4106
4149
  let previousPackage;
@@ -4662,6 +4705,9 @@ export const proxyGuardCommand = {
4662
4705
  persistUpdaterState("record successful update", () => recordSuccessfulUpdate(result.latestVersion));
4663
4706
  updateRetryAttempts = 0;
4664
4707
  updateRetryVersion = null;
4708
+ // The new version is serving and the selection pair is committed, so
4709
+ // the build that just fell out of retention is unreferenced.
4710
+ await prunePackageStore("after update");
4665
4711
  // The replacement proxy starts a worker running the new version.
4666
4712
  await shutdownProxyOtelLogs().catch(() => undefined);
4667
4713
  process.exit(0);
@@ -4769,6 +4815,14 @@ export const proxyGuardCommand = {
4769
4815
  }, delay);
4770
4816
  updateRetryTimeout.unref?.();
4771
4817
  };
4818
+ // Not gated on canAutoUpdate: builds left by earlier runs are dead weight
4819
+ // whether or not this process will install another one. The periodic pass
4820
+ // is cheap (one readdir and one process listing) and covers a startup pass
4821
+ // that ran before the supervisor had recorded this updater as the owner.
4822
+ if (updaterOnly) {
4823
+ setTimeout(() => void prunePackageStore("startup"), 60_000).unref();
4824
+ setInterval(() => void prunePackageStore("periodic"), UPDATE_CHECK_INTERVAL_MS).unref();
4825
+ }
4772
4826
  // Run first check after a short delay, then on interval
4773
4827
  if (canAutoUpdate) {
4774
4828
  updateCheckTimeout = setTimeout(runUpdateCheck, 30_000);
@@ -0,0 +1,8 @@
1
+ import type { ProxyPackagePruneOptions, ProxyPackagePruneResult } from "../types/index.js";
2
+ /**
3
+ * Keep the selected pair (active and rollback) and remove every other build the
4
+ * installer generated. Nothing is removed unless the active selection is valid,
5
+ * the live process list could be read, and the build is neither referenced by a
6
+ * running process nor recently written.
7
+ */
8
+ export declare function pruneProxyPackages(options: ProxyPackagePruneOptions): Promise<ProxyPackagePruneResult>;
@@ -0,0 +1,219 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { lstat, readdir, rename, rm } from "node:fs/promises";
5
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { readProxyPackageSelection } from "./globalInstaller.js";
8
+ const execFileAsync = promisify(execFile);
9
+ // Only the names globalInstaller.ts generates: `<version>`,
10
+ // `<version>-release-<uuid>` and `<version>-polyfills-<digest>-<uuid>`. Anything
11
+ // else in the store (a hand-made `-official` copy, an in-flight `.install-*`
12
+ // staging tree) is not ours to delete.
13
+ const GENERATED_BUILD_NAME = /^\d+\.\d+\.\d+(?:-release-[0-9a-f-]{36}|-polyfills-[0-9a-f]{16}-[0-9a-f-]{36})?$/;
14
+ const TOMBSTONE_PREFIX = ".prune-";
15
+ const DEFAULT_MIN_AGE_MS = 60 * 60_000;
16
+ function entryScriptOf(value) {
17
+ return typeof value === "object" &&
18
+ value !== null &&
19
+ "entryScript" in value &&
20
+ typeof value.entryScript === "string"
21
+ ? value.entryScript
22
+ : undefined;
23
+ }
24
+ function canonical(path) {
25
+ try {
26
+ return realpathSync(path);
27
+ }
28
+ catch {
29
+ return path;
30
+ }
31
+ }
32
+ /** First path segment below the store, i.e. the build directory a file lives in. */
33
+ function buildDirectoryOf(storeRoot, path) {
34
+ const rel = relative(storeRoot, canonical(path));
35
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
36
+ return null;
37
+ }
38
+ return rel.split(sep)[0] ?? null;
39
+ }
40
+ /**
41
+ * Read selections.json without validating the builds it names: a build that
42
+ * failed validation once must still count as referenced, not as garbage.
43
+ */
44
+ function rawSelectedEntryScripts(packagesDir) {
45
+ try {
46
+ const state = JSON.parse(readFileSync(join(packagesDir, "selections.json"), "utf8"));
47
+ if (typeof state !== "object" || state === null) {
48
+ return [];
49
+ }
50
+ return [
51
+ "active" in state ? entryScriptOf(state.active) : undefined,
52
+ "previous" in state ? entryScriptOf(state.previous) : undefined,
53
+ ].filter((entry) => entry !== undefined);
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ async function listProcessCommandLines() {
60
+ if (process.platform === "win32") {
61
+ return null;
62
+ }
63
+ try {
64
+ const { stdout } = await execFileAsync("ps", ["-axo", "command="], {
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ timeout: 10_000,
67
+ });
68
+ return stdout.split("\n");
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ function mentionsDirectory(line, directory) {
75
+ return (line.includes(`${directory}${sep}`) ||
76
+ line.includes(`${directory} `) ||
77
+ line.endsWith(directory));
78
+ }
79
+ /**
80
+ * Keep the selected pair (active and rollback) and remove every other build the
81
+ * installer generated. Nothing is removed unless the active selection is valid,
82
+ * the live process list could be read, and the build is neither referenced by a
83
+ * running process nor recently written.
84
+ */
85
+ export async function pruneProxyPackages(options) {
86
+ const skip = (reason) => ({
87
+ skipped: reason,
88
+ removed: [],
89
+ retained: [],
90
+ failed: [],
91
+ });
92
+ let storeRoot;
93
+ try {
94
+ storeRoot = realpathSync(options.packagesDir);
95
+ }
96
+ catch {
97
+ return skip("package store does not exist");
98
+ }
99
+ const buildDirectories = (entryScripts) => new Set(entryScripts
100
+ .filter((entry) => entry !== undefined)
101
+ .map((entry) => buildDirectoryOf(storeRoot, entry))
102
+ .filter((name) => name !== null));
103
+ // Read again before every removal, not once: a pass can outlive the selection
104
+ // it started from. Null means the active selection no longer validates.
105
+ const readSelected = () => {
106
+ const active = readProxyPackageSelection(options.packagesDir);
107
+ if (!active) {
108
+ return null;
109
+ }
110
+ return buildDirectories([
111
+ active.entryScript,
112
+ readProxyPackageSelection(options.packagesDir, true)?.entryScript,
113
+ ...rawSelectedEntryScripts(options.packagesDir),
114
+ ]);
115
+ };
116
+ const selected = readSelected();
117
+ if (!selected) {
118
+ return skip("no validated active package selection");
119
+ }
120
+ const running = buildDirectories([options.currentEntryScript]);
121
+ const entries = await readdir(storeRoot, { withFileTypes: true });
122
+ const failed = [];
123
+ // A pass killed between rename and rm leaves a tombstone; nothing else reads it.
124
+ for (const entry of entries) {
125
+ if (!options.dryRun &&
126
+ entry.isDirectory() &&
127
+ entry.name.startsWith(TOMBSTONE_PREFIX)) {
128
+ await rm(join(storeRoot, entry.name), {
129
+ recursive: true,
130
+ force: true,
131
+ }).catch((error) => {
132
+ failed.push({ name: entry.name, message: String(error) });
133
+ });
134
+ }
135
+ }
136
+ const candidates = entries
137
+ .filter((entry) => entry.isDirectory() && GENERATED_BUILD_NAME.test(entry.name))
138
+ .map((entry) => entry.name)
139
+ .sort();
140
+ const unselected = candidates.filter((name) => !selected.has(name));
141
+ const processLines = unselected.length
142
+ ? await (options.listProcessCommandLines ?? listProcessCommandLines)()
143
+ : [];
144
+ if (processLines === null) {
145
+ return skip("running processes could not be enumerated");
146
+ }
147
+ const retained = candidates
148
+ .filter((name) => selected.has(name))
149
+ .map((name) => ({ name, reason: "selected" }));
150
+ const removed = [];
151
+ const now = options.now ?? Date.now();
152
+ const minAgeMs = options.minAgeMs ?? DEFAULT_MIN_AGE_MS;
153
+ // A process may name the store through a symlinked spelling (/var vs
154
+ // /private/var), so match the path as configured as well as the real one.
155
+ const storeSpellings = [
156
+ ...new Set([storeRoot, resolve(options.packagesDir)]),
157
+ ];
158
+ for (const name of unselected) {
159
+ const directory = join(storeRoot, name);
160
+ const retain = (reason) => {
161
+ retained.push({ name, reason });
162
+ };
163
+ if (running.has(name) ||
164
+ processLines.some((line) => storeSpellings.some((store) => mentionsDirectory(line, join(store, name))))) {
165
+ retain("in-use");
166
+ continue;
167
+ }
168
+ try {
169
+ const stats = await lstat(directory);
170
+ if (now - Math.max(stats.mtimeMs, stats.ctimeMs) < minAgeMs) {
171
+ retain("recent");
172
+ continue;
173
+ }
174
+ if (options.dryRun) {
175
+ removed.push(name);
176
+ continue;
177
+ }
178
+ // The only thing left between this check and the rename is the rename. A
179
+ // replacement updater may have selected this build, or taken over, since
180
+ // the pass began.
181
+ const latest = readSelected();
182
+ if (!latest) {
183
+ return {
184
+ skipped: "package selection became invalid during the pass",
185
+ removed,
186
+ retained,
187
+ failed,
188
+ };
189
+ }
190
+ if (latest.has(name)) {
191
+ retain("selected");
192
+ continue;
193
+ }
194
+ if (options.isCurrentOwner && !options.isCurrentOwner()) {
195
+ return {
196
+ skipped: "update ownership changed during the pass",
197
+ removed,
198
+ retained,
199
+ failed,
200
+ };
201
+ }
202
+ // Rename first: a half-deleted directory under its real name would pass
203
+ // installStagedProxyPackage's existsSync() shortcut and then fail validation.
204
+ const tombstone = join(storeRoot, `${TOMBSTONE_PREFIX}${name}-${randomUUID()}`);
205
+ await rename(directory, tombstone);
206
+ removed.push(name);
207
+ await rm(tombstone, { recursive: true, force: true });
208
+ }
209
+ catch (error) {
210
+ if (error.code !== "ENOENT") {
211
+ failed.push({
212
+ name,
213
+ message: error instanceof Error ? error.message : String(error),
214
+ });
215
+ }
216
+ }
217
+ }
218
+ return { removed, retained, failed };
219
+ }
@@ -4456,6 +4456,36 @@ export type ProxyPackageUpgradeOptions = ProxyStagedInstallOptions & {
4456
4456
  isCurrentOwner?: () => boolean;
4457
4457
  onPolyfills?: (report: ProxyPackagePolyfillReport) => void;
4458
4458
  };
4459
+ /** Why a build in the package store was left alone by a retention pass. */
4460
+ export type ProxyPackagePruneRetention = "selected" | "in-use" | "recent";
4461
+ /** Retention pass over the immutable package store, with injectable process listing. */
4462
+ export type ProxyPackagePruneOptions = {
4463
+ packagesDir: string;
4464
+ /** Entry script of the calling process; its build is never removed. */
4465
+ currentEntryScript?: string;
4466
+ /** Builds written more recently than this are left for a later pass. */
4467
+ minAgeMs?: number;
4468
+ now?: number;
4469
+ /** Report what would be removed without touching the store. */
4470
+ dryRun?: boolean;
4471
+ /** Checked before each removal; the pass stops as soon as it returns false. */
4472
+ isCurrentOwner?: () => boolean;
4473
+ /** Command lines of live processes, or null when they cannot be listed. */
4474
+ listProcessCommandLines?: () => Promise<string[] | null>;
4475
+ };
4476
+ export type ProxyPackagePruneResult = {
4477
+ /** Set when the pass declined to remove anything or stopped early, with the reason. */
4478
+ skipped?: string;
4479
+ removed: string[];
4480
+ retained: {
4481
+ name: string;
4482
+ reason: ProxyPackagePruneRetention;
4483
+ }[];
4484
+ failed: {
4485
+ name: string;
4486
+ message: string;
4487
+ }[];
4488
+ };
4459
4489
  /** Capture a trusted baseline for a locally patched package without activation. */
4460
4490
  export type ProxyPolyfillArgs = {
4461
4491
  action: "capture";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "12.54.0",
3
+ "version": "12.54.2",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002/call) for routing, tool selection and context budgeting. MCP-native (4 transports), voice TTS/STT/realtime, RAG, agents, memory, compaction, 9 observability exporters. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · TypeSafe Jev and more.",
6
6
  "author": {
@@ -217,6 +217,7 @@
217
217
  "test:tool-dedup": "pnpm exec tsx test/continuous-test-suite-tool-dedup.ts",
218
218
  "test:tools-manager-truncation": "pnpm exec tsx test/continuous-test-suite-tools-manager-truncation.ts",
219
219
  "test:tooling-scripts": "pnpm exec tsx test/continuous-test-suite-tooling-scripts.ts",
220
+ "test:tooling-shell-safety": "pnpm exec tsx test/continuous-test-suite-tooling-shell-safety.ts",
220
221
  "test:model-not-found-retryable": "pnpm exec tsx test/continuous-test-suite-model-not-found-retryable.ts",
221
222
  "test:reasoning-parity-live": "pnpm exec tsx test/continuous-test-suite-reasoning-parity-live.ts",
222
223
  "test:ci": "pnpm run test && pnpm run test:client && pnpm run test:hitl",