@evandu/super-pi 0.1.95 → 0.1.97

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/bin/super-pi.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, spawn } from "node:child_process";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
@@ -15,6 +15,31 @@ const home = process.env.SUPER_PI_AUTOSTART_HOME ?? homedir();
15
15
  const envPort = Number(process.env.SUPER_PI_PORT ?? 3080);
16
16
  const defaultPort = Number.isInteger(envPort) && envPort > 0 ? envPort : 3080;
17
17
 
18
+ /**
19
+ * This installation's own version.
20
+ *
21
+ * READ AT RUN TIME, not baked in, because there can be more than one Super Pi on a machine and the one
22
+ * that matters is the one that is RUNNING. The two usual places are the global npm install - which is
23
+ * what the `super_pi` command runs - and the Pi extension's own install - which is what the
24
+ * `/super_pi` slash command loads. They are separate installations on purpose (Super Pi must not
25
+ * require Pi to be installed first), so their versions can differ, and a person comparing the two needs
26
+ * to see which is which. A bare version number cannot say that; a version number AND a path can.
27
+ */
28
+ function ownVersion() {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
31
+ return { version: pkg.version ?? "unknown", location: root };
32
+ } catch {
33
+ return { version: "unknown", location: root };
34
+ }
35
+ }
36
+
37
+ /** The line that names what is running, so two installations can be told apart at a glance. */
38
+ function runningBanner(url) {
39
+ const { version, location } = ownVersion();
40
+ return `Super Pi v${version} is running at ${url}\n installed at ${location}`;
41
+ }
42
+
18
43
  function options(args) {
19
44
  const value = { port: defaultPort, host: process.env.SUPER_PI_HOST ?? "0.0.0.0", browser: true };
20
45
  for (let i = 0; i < args.length; i += 1) {
@@ -57,12 +82,12 @@ const command = implicitStart ? "start" : raw?.toLowerCase();
57
82
  const args = implicitStart ? [raw, ...rest] : rest;
58
83
  try {
59
84
  if (command === "help" || command === "--help" || command === "-h") { usage(); process.exit(0); }
60
- if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}` : `Super Pi is running at ${url}`); process.exit(0); }
85
+ if (!command) { const value = options(args); await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url)); process.exit(0); }
61
86
  if (command === "autostart" || command === "startup") { const action = args.shift()?.toLowerCase() ?? "status"; const value = options(args); if (action === "enable") await enable(value); else if (action === "disable") await disable(); else if (action === "status") await status(); else throw new Error("Autostart action must be enable, disable, or status."); process.exit(0); }
62
87
  if (!["start", "stop", "restart", "update"].includes(command)) throw new Error(`Unknown command: ${command}`);
63
88
  if (command === "update") { const npm = platform === "win32" ? "npm.cmd" : "npm"; const args = prefix ? ["install", "--prefix", prefix, "@evandu/super-pi"] : ["install", "-g", "@evandu/super-pi"]; const child = spawn(npm, args, { stdio: "inherit", shell: platform === "win32", env: process.env }); const code = await new Promise((resolve) => child.on("close", resolve)); if (code !== 0) process.exit(code ?? 1); }
64
89
  const value = options(args);
65
90
  if (command === "stop") { console.log(await running(value.port) ? (await stop(value.port) ? `Shutdown requested on port ${value.port}.` : "Shutdown request failed.") : `Super Pi is not running on port ${value.port}.`); process.exit(0); }
66
91
  if ((command === "restart" || command === "update") && await running(value.port)) { await stop(value.port); await new Promise((resolve) => setTimeout(resolve, 600)); }
67
- await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}` : `Super Pi is running at ${url}`);
68
- } catch (error) { console.error(error instanceof Error ? error.message : String(error)); usage(); process.exit(1); }
92
+ await start(value); const url = `http://127.0.0.1:${value.port}`; if (value.browser) openBrowser(url); console.log(value.browser ? `Opened ${url}\n${runningBanner(url)}` : runningBanner(url));
93
+ } catch (error) { console.error(error instanceof Error ? error.message : String(error)); usage(); process.exit(1); }
@@ -24,5 +24,5 @@
24
24
  "src/client/types.ts": "6487df00c164370550b5977f9b23b17e22aff4bfb59dfe1e52200042340e3769",
25
25
  "src/client/vite-env.d.ts": "65996936fbb042915f7b74a200fcdde7e410f32a669b1ab9597cfaa4b0faddb5"
26
26
  },
27
- "recordedAt": "2026-10-20T13:28:21.946Z"
27
+ "recordedAt": "2026-10-20T17:20:14.918Z"
28
28
  }
package/dist/extension.js CHANGED
@@ -83,14 +83,24 @@ export default function (pi) {
83
83
  if (!running) {
84
84
  ctx.ui.notify?.("Starting Super Pi web server...", "info");
85
85
  const serverScript = resolveServerScript();
86
- // Identify current pi command to pass down to Super Pi
87
- const piCommand = process.argv[1] || "pi";
86
+ // The Pi that is hosting Super Pi, passed down so that the ONE-SHOT work - the sub-agents,
87
+ // which spawn a Pi process - runs on the same Pi that is running this extension.
88
+ //
89
+ // IT IS NOT `SUPER_PI_PI_COMMAND`, and that difference is load-bearing. That name means "the user
90
+ // named this Pi", and naming a Pi is what tells Super Pi to run CONVERSATIONS by spawning it
91
+ // rather than by using Pi's SDK - the SDK is a library, and it can only be the one that came with
92
+ // Super Pi. So a Pi named there cannot be a conversation's Pi, and a conversation that spawns a
93
+ // process for every turn is the slow architecture this all replaced.
94
+ //
95
+ // Setting it here therefore made every conversation started from inside Pi slow, with nothing
96
+ // saying why. The hosting Pi belongs to the one-shot paths, and this is where it is said.
97
+ const hostPi = process.argv[1] || "pi";
88
98
  const child = spawn(process.execPath, [serverScript], {
89
99
  detached: true,
90
100
  stdio: "ignore",
91
101
  env: {
92
102
  ...process.env,
93
- SUPER_PI_PI_COMMAND: piCommand,
103
+ SUPER_PI_HOST_PI: hostPi,
94
104
  SUPER_PI_PORT: String(port),
95
105
  },
96
106
  });
@@ -126,6 +126,30 @@ export function resolvePiCommand(options = {}) {
126
126
  searched: [...onPath, ...globalDirs],
127
127
  };
128
128
  }
129
+ // THE PI THAT IS HOSTING SUPER PI, when Super Pi was started from inside Pi itself.
130
+ //
131
+ // It is NOT the same thing as an explicitly named Pi, and the difference is the whole point:
132
+ // `SUPER_PI_PI_COMMAND` means "the USER named this Pi", and naming a Pi is what tells Super Pi to
133
+ // run CONVERSATIONS by spawning it - the SDK is a library, and it can only be the one that came with
134
+ // Super Pi. A conversation that spawns a process for every turn is the slow architecture this all
135
+ // replaced, so the hosting Pi must NOT set that flag.
136
+ //
137
+ // What the hosting Pi IS good for is the one-shot work: the sub-agents spawn a Pi process, and they
138
+ // should run on the same Pi that is hosting Super Pi. So it is preferred over the bundled copy here,
139
+ // without being a user override.
140
+ const hostPi = (env.SUPER_PI_HOST_PI ?? "").trim();
141
+ if (hostPi) {
142
+ if (/[\\/]/.test(hostPi)) {
143
+ const full = nativeEntry(hostPi, platform) ?? hostPi;
144
+ if (exists(full))
145
+ return { found: true, command: full, via: "the Pi that is hosting Super Pi", explicit: false };
146
+ }
147
+ else {
148
+ const hit = firstHit([hostPi], [...onPath, ...globalDirs], exists, platform);
149
+ if (hit)
150
+ return { found: true, command: hit.command, via: `the Pi that is hosting Super Pi (${hit.dir})`, explicit: false };
151
+ }
152
+ }
129
153
  // THE COPY OF PI THAT CAME WITH SUPER PI, and it comes FIRST among the automatic choices.
130
154
  //
131
155
  // Super Pi depends on Pi's package, so npm installs it and puts its `pi` executable in
@@ -515,6 +515,16 @@ export declare class PiRpcRuntime implements AgentRuntime {
515
515
  private warnedMissingExtension;
516
516
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
517
517
  private warnedMissingSdk;
518
+ /**
519
+ * How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
520
+ *
521
+ * is the slower one, and it exists for the test suite: those suites drive a fake Pi that
522
+ * speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
523
+ * it. It is an explicit request rather than something inferred from which Pi happens to be named,
524
+ * because inferring it is what made the Pi extension start conversations on the slow path without
525
+ * saying why.
526
+ */
527
+ private readonly sessionMode;
518
528
  /**
519
529
  * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
520
530
  * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
@@ -571,6 +581,7 @@ export declare class PiRpcRuntime implements AgentRuntime {
571
581
  toolsEndpoint?: string;
572
582
  uploadsDir?: string;
573
583
  sessionFactory?: PiSessionFactory;
584
+ sessionMode?: "sdk" | "oneshot";
574
585
  });
575
586
  /**
576
587
  * The configuration of the session that was built for a conversation, for tests to inspect.
@@ -598,6 +609,14 @@ export declare class PiRpcRuntime implements AgentRuntime {
598
609
  };
599
610
  thinkingLevel?: string;
600
611
  } | undefined;
612
+ /**
613
+ * Which path a CONVERSATION on this runtime takes.
614
+ *
615
+ * Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
616
+ * the version number said 0.1.95 while every conversation was still spawning a process per turn,
617
+ * and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
618
+ */
619
+ get conversationPath(): "sdk" | "oneshot";
601
620
  /**
602
621
  * Whether the SDK-backed session layer can be used at all.
603
622
  *
@@ -303,6 +303,16 @@ export class PiRpcRuntime {
303
303
  warnedMissingExtension = false;
304
304
  /** Latches the "Pi's SDK is not installed" warning, for the same reason. */
305
305
  warnedMissingSdk = false;
306
+ /**
307
+ * How a CONVERSATION runs: on a persistent SDK session, or by spawning a Pi for every turn.
308
+ *
309
+ * is the slower one, and it exists for the test suite: those suites drive a fake Pi that
310
+ * speaks the RPC protocol over a pipe, and a program is not a library - the session path cannot run
311
+ * it. It is an explicit request rather than something inferred from which Pi happens to be named,
312
+ * because inferring it is what made the Pi extension start conversations on the slow path without
313
+ * saying why.
314
+ */
315
+ sessionMode;
306
316
  /**
307
317
  * Agent definitions are read from disk, never cached. A file is cheap to parse, and re-reading it
308
318
  * per turn is the whole point: editing an agent's prompt or tool allowlist changes the NEXT message
@@ -364,6 +374,8 @@ export class PiRpcRuntime {
364
374
  this.pi = resolvePiCommand({ command });
365
375
  if (!this.pi.found)
366
376
  console.warn(`PiRpcRuntime: ${this.pi.reason}`);
377
+ this.sessionMode = this.options.sessionMode
378
+ ?? (process.env.SUPER_PI_SESSION_MODE === "oneshot" ? "oneshot" : "sdk");
367
379
  this.sessionPool = new PiSessionPool(sessionDir, repositoryRootFrom(), undefined, (conversationId) => {
368
380
  // A session's credential dies with the session. The session is what HELD the token - its
369
381
  // extension read it out of the environment when it was loaded - so a session that is gone must
@@ -391,6 +403,16 @@ export class PiRpcRuntime {
391
403
  builtSessionConfig(conversationId) {
392
404
  return this.builtSessions.get(conversationId);
393
405
  }
406
+ /**
407
+ * Which path a CONVERSATION on this runtime takes.
408
+ *
409
+ * Exposed so a test can assert it. The bug this exists to prevent was invisible from the outside:
410
+ * the version number said 0.1.95 while every conversation was still spawning a process per turn,
411
+ * and nothing said why. A test that can ask "would a conversation here spawn a Pi?" can catch that.
412
+ */
413
+ get conversationPath() {
414
+ return this.sessionMode;
415
+ }
394
416
  /**
395
417
  * Whether the SDK-backed session layer can be used at all.
396
418
  *
@@ -919,18 +941,26 @@ export class PiRpcRuntime {
919
941
  yield* this.answerOneShot(input);
920
942
  return;
921
943
  }
922
- // AN EXPLICITLY NAMED PI MEANS THE ONE-SHOT PATH, and this is a rule about the user's CONFIGURATION
923
- // rather than about speed.
944
+ // WHICH PATH A CONVERSATION RUNS ON, and why it is NOT decided by which Pi was named.
945
+ //
946
+ // The session path does not use a Pi COMMAND at all: it imports Pi as a library, and the library it
947
+ // imports is the one installed next to Super Pi. A named command is a PROGRAM, and the session path
948
+ // cannot run a program as itself.
949
+ //
950
+ // So the two are different questions, and they are asked of different variables:
924
951
  //
925
- // `SUPER_PI_PI_COMMAND` says "use THIS Pi". The session path does not use a Pi command at all: it
926
- // imports Pi as a library, and the library it imports is the one installed next to Super Pi. So a
927
- // server configured with an explicit command would silently ignore it - the user would point Super Pi
928
- // at one Pi and get a different one, with nothing saying so.
952
+ // - `SUPER_PI_HOST_PI` - the Pi that is hosting Super Pi, set by the Pi extension. It steers the
953
+ // ONE-SHOT work (the sub-agents spawn a Pi) and nothing else. It does NOT redirect conversations.
954
+ // - `SUPER_PI_SESSION_MODE=oneshot` - the explicit request to run conversations by spawning a Pi
955
+ // instead of by using the SDK. It is slower, and it is here for the test suite, which drives a fake
956
+ // Pi that speaks the RPC protocol - a program, not a library.
929
957
  //
930
- // A named command is therefore honoured: the child process IS that command, and the one-shot path is
931
- // the only path that can promise that. This also keeps the test suite's fake Pi meaningful, because
932
- // a test that names a fake Pi is naming a program that speaks the RPC protocol, not a library.
933
- if (this.pi.found && this.pi.explicit) {
958
+ // THE BUG THIS FIXES. The extension used to set `SUPER_PI_PI_COMMAND`, and an explicitly named Pi
959
+ // used to mean the one-shot path - so every conversation started from inside Pi spawned a process for
960
+ // every turn. That is the exact architecture this refactor replaced, and it was still running for
961
+ // anyone who started Super Pi the way most people do. The version number said 0.1.95; the speed said
962
+ // 0.1.84.
963
+ if (this.sessionMode === "oneshot") {
934
964
  yield* this.answerOneShot(input);
935
965
  return;
936
966
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,8 @@
19
19
  "bin",
20
20
  "scripts/restart-server.mjs",
21
21
  "README.md",
22
- "assets/knowledge-model"
22
+ "assets/knowledge-model",
23
+ "scripts/patch-brace-expansion.mjs"
23
24
  ],
24
25
  "engines": {
25
26
  "node": ">=22.19.0"
@@ -35,7 +36,8 @@
35
36
  "start": "node dist/server/index.js",
36
37
  "restart:server": "node scripts/restart-server.mjs",
37
38
  "smoke": "npm run build && node scripts/smoke.mjs",
38
- "check": "node scripts/run-check.mjs"
39
+ "check": "node scripts/run-check.mjs",
40
+ "postinstall": "node scripts/patch-brace-expansion.mjs"
39
41
  },
40
42
  "check:logic": [
41
43
  "scripts/stream-checkpoint.test.mjs",
@@ -0,0 +1,179 @@
1
+ // Repairs one vulnerable transitive dependency that Super Pi cannot fix from the outside.
2
+ //
3
+ // THE PROBLEM. Pi's package ships an `npm-shrinkwrap.json`, and a shrinkwrap pins a dependency's
4
+ // whole subtree. Pi's shrinkwrap pins `brace-expansion@5.0.9`, and npm honours a shrinkwrap over
5
+ // everything else - so neither `overrides` (in either the flat or the nested form) nor `npm audit fix`
6
+ // can move it. `npm audit` therefore reports "1 high severity vulnerability" to every person who
7
+ // installs Super Pi, and the finding is real: brace-expansion 5.0.9 is quadratic-time on `{a,b}` and
8
+ // recurses without bound on nested brace groups, so a pattern that reaches it can burn the CPU or the
9
+ // stack.
10
+ //
11
+ // THE REACHABILITY, so this is not a reflex. brace-expansion is reached through `minimatch`, and in
12
+ // Pi's tree `minimatch` is used by the package manager - to match the paths of installed extensions
13
+ // and skills. Those names come from files on disk, so a hostile workspace is enough to feed a pattern
14
+ // in. It is a denial of service, not a privilege escalation, but it is reachable by opening a
15
+ // repository, and a workspace is exactly what Super Pi is pointed at.
16
+ //
17
+ // THE FIX. `brace-expansion@5.0.12` is the patched release, and it has the same file layout as
18
+ // 5.0.9 (both are `tshy` builds with the same `dist/commonjs` + `dist/esm` shape), so replacing the
19
+ // installed copy is a file-level swap with no re-linking and no API change.
20
+ //
21
+ // WHY IT IS A POSTINSTALL AND NOT A DEPENDENCY. A dependency cannot express "a different version of
22
+ // my dependency's dependency", because the shrinkwrap is the whole point of a shrinkwrap. The only
23
+ // place left is the install itself.
24
+ //
25
+ // WHAT IT WILL NOT DO. It never fails an install. A patched copy that cannot be fetched leaves the
26
+ // tree as it was, and the script says so in one line. An install that cannot finish because of a
27
+ // security patch is a worse outcome than an install that finishes with a known advisory, so the script
28
+ // only ever warns.
29
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, renameSync, cpSync } from "node:fs";
30
+ import { dirname, join, relative, resolve } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { execFileSync } from "node:child_process";
33
+ import { tmpdir } from "node:os";
34
+
35
+ /** The patched release. 5.0.9 is what Pi's shrinkwrap pins; 5.0.12 carries the fix. */
36
+ const PATCHED_VERSION = "5.0.12";
37
+ /** The first fixed version. Below this, the advisory applies. */
38
+ const VULNERABLE_BELOW = "5.0.12";
39
+
40
+ const here = dirname(fileURLToPath(import.meta.url));
41
+ const repositoryRoot = resolve(here, "..");
42
+
43
+ /** The lockfile-relative path of a directory, in the form a lockfile's keys take. */
44
+ const lockKeyOf = (directory) => relative(repositoryRoot, directory).split(/[\\/]/).filter(Boolean).join("/");
45
+
46
+ /** Where Pi's package keeps its own `brace-expansion`, if it keeps one. */
47
+ function findVulnerableCopies() {
48
+ const found = [];
49
+ const candidates = [
50
+ join(repositoryRoot, "node_modules", "@earendil-works", "pi-coding-agent", "node_modules", "brace-expansion"),
51
+ join(repositoryRoot, "node_modules", "brace-expansion"),
52
+ ];
53
+ for (const directory of candidates) {
54
+ const manifest = join(directory, "package.json");
55
+ if (!existsSync(manifest)) continue;
56
+ let version = "";
57
+ try { version = JSON.parse(readFileSync(manifest, "utf8")).version ?? ""; } catch { continue; }
58
+ if (!version) continue;
59
+ if (compareVersions(version, VULNERABLE_BELOW) >= 0) continue;
60
+ found.push({ directory, version, lockKey: lockKeyOf(directory) });
61
+ }
62
+ return found;
63
+ }
64
+
65
+ /** Numeric version comparison that does not care about a leading `v` or a prerelease tag. */
66
+ function compareVersions(a, b) {
67
+ const parts = (value) => String(value).replace(/^v/, "").split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
68
+ const left = parts(a);
69
+ const right = parts(b);
70
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
71
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
72
+ if (difference !== 0) return difference < 0 ? -1 : 1;
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ /**
78
+ * Replaces one installed copy with the patched release.
79
+ *
80
+ * The patched copy is fetched with `npm install` into a throwaway directory - npm's own
81
+ * fetch-and-verify path - rather than `npm pack` plus `tar`, which needs a `tar` on PATH whose flags
82
+ * differ between platforms. It is laid out exactly the way npm would have laid it out.
83
+ */
84
+ function patch(directory) {
85
+ const scratch = join(tmpdir(), `super-pi-brace-${process.pid}-${Date.now()}`);
86
+ try {
87
+ mkdirSync(scratch, { recursive: true });
88
+ const stage = join(scratch, "stage");
89
+ mkdirSync(stage, { recursive: true });
90
+ writeFileSync(join(stage, "package.json"), JSON.stringify({ name: "super-pi-brace-stage", version: "0.0.0", private: true }));
91
+ execFileSync("npm", ["install", `brace-expansion@${PATCHED_VERSION}`, "--no-audit", "--no-fund", "--silent", "--omit=dev"], {
92
+ cwd: stage,
93
+ stdio: ["ignore", "ignore", "pipe"],
94
+ shell: process.platform === "win32",
95
+ });
96
+ const unpacked = join(stage, "node_modules", "brace-expansion");
97
+ if (!existsSync(join(unpacked, "package.json"))) throw new Error("the staged install produced no package");
98
+ // The old copy is moved aside rather than deleted first, so a failure mid-swap cannot leave the
99
+ // tree with half a package in it.
100
+ const previous = `${directory}.super-pi-backup`;
101
+ rmSync(previous, { recursive: true, force: true });
102
+ renameSync(directory, previous);
103
+ try {
104
+ cpSync(unpacked, directory, { recursive: true });
105
+ } catch (error) {
106
+ // The swap failed halfway: put the old copy back rather than leaving the tree broken.
107
+ rmSync(directory, { recursive: true, force: true });
108
+ renameSync(previous, directory);
109
+ throw error;
110
+ }
111
+ rmSync(previous, { recursive: true, force: true });
112
+ return true;
113
+ } finally {
114
+ rmSync(scratch, { recursive: true, force: true });
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The integrity hash npm recorded for the patched version, so the lockfile still describes a tree npm
120
+ * can verify. It comes from the registry metadata npm itself trusts.
121
+ */
122
+ function readIntegrity() {
123
+ try {
124
+ const output = execFileSync("npm", ["view", `brace-expansion@${PATCHED_VERSION}`, "dist.integrity", "--silent"], {
125
+ stdio: ["ignore", "pipe", "pipe"],
126
+ shell: process.platform === "win32",
127
+ encoding: "utf8",
128
+ });
129
+ return output.trim() || undefined;
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Rewrites the lockfile's entry for a patched package.
137
+ *
138
+ * WITHOUT THIS THE PATCH IS INVISIBLE. `npm audit` reads the LOCKFILE, not the files on disk - it
139
+ * compares the resolved versions against the advisory database. So a tree that is genuinely patched
140
+ * still reports "1 high severity vulnerability", and the person who installed Super Pi is left looking
141
+ * at a warning that no amount of reinstalling will clear.
142
+ */
143
+ function updateLockfile(lockKey, version, integrity) {
144
+ const lockPath = join(repositoryRoot, "package-lock.json");
145
+ if (!existsSync(lockPath)) return false;
146
+ let lock;
147
+ try { lock = JSON.parse(readFileSync(lockPath, "utf8")); }
148
+ catch { return false; }
149
+ const entry = lock.packages?.[lockKey];
150
+ if (!entry || entry.version === version) return false;
151
+ entry.version = version;
152
+ if (integrity) entry.integrity = integrity;
153
+ if (entry.resolved) entry.resolved = entry.resolved.replace(/-\/[^/]*\.tgz$/, `-/brace-expansion-${version}.tgz`);
154
+ writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
155
+ return true;
156
+ }
157
+
158
+ const copies = findVulnerableCopies();
159
+ if (copies.length === 0) {
160
+ console.log("[super-pi] brace-expansion is not a vulnerable version here; nothing to patch.");
161
+ } else {
162
+ let patched = 0;
163
+ for (const copy of copies) {
164
+ try {
165
+ if (!patch(copy.directory)) continue;
166
+ patched += 1;
167
+ console.log(`[super-pi] patched brace-expansion ${copy.version} -> ${PATCHED_VERSION} (${copy.directory})`);
168
+ // And the lockfile, so `npm audit` stops reporting a tree that is no longer vulnerable.
169
+ if (updateLockfile(copy.lockKey, PATCHED_VERSION, readIntegrity())) {
170
+ console.log("[super-pi] the lockfile now records the patched version, so `npm audit` stops reporting it.");
171
+ }
172
+ } catch (error) {
173
+ // NEVER fail the install over this. An install that cannot finish is worse than an install that
174
+ // finishes with a known advisory, and the advisory is reported by `npm audit` either way.
175
+ console.warn(`[super-pi] could not patch brace-expansion in ${copy.directory}: ${error instanceof Error ? error.message : String(error)}. Run \`npm audit fix\` or reinstall to try again.`);
176
+ }
177
+ }
178
+ if (patched === 0) console.warn("[super-pi] brace-expansion is still at a vulnerable version; `npm audit` will keep reporting it. This is Pi's own pinned dependency tree.");
179
+ }