Compression extras
@@ -434,33 +544,57 @@ export default function TokenSaverClient() {
{headroomExtras.available.map((extra) => {
const installed = !!headroomExtras.extras[extra];
const pending = pendingExtras.includes(extra);
+ const extraTitle =
+ extra === "code"
+ ? "tree-sitter AST compression for code responses"
+ : "Kompress-v2 HF model for prose/agentic traces (~+1GB)";
+
+ if (installed) {
+ const active = extra === "code" ? codeAware : kompress;
+ return (
+
+ toggleExtraActive(extra, !active)}
+ />
+ [{extra}]
+ handleRemoveExtra(extra)}
+ disabled={removingExtra === extra}
+ className="ml-1 text-error underline hover:opacity-80 disabled:opacity-50"
+ title={`Uninstall [${extra}]`}
+ >
+ {removingExtra === extra ? "Uninstalling…" : "Uninstall"}
+
+
+ );
+ }
+
return (
togglePendingExtra(extra)}
/>
[{extra}]
-
- {installed ? "installed" : "not installed"}
-
+ not installed
);
})}
@@ -479,16 +613,26 @@ export default function TokenSaverClient() {
{extrasActionError && (
{extrasActionError}
)}
+ {restartingProxy && (
+ Restarting proxy…
+ )}
+ {(extrasActionLoading || removingExtra) && installLog && (
+
+ {installLog}
+
+ )}
- Default install is [proxy] only (SmartCrusher for
- JSON). Adding [code] enables AST compression
+ Installing adds the package; use on/off{" "}
+ to activate it (restarts the proxy). Default install is{" "}
+ [proxy] only (SmartCrusher for JSON). Adding{" "}
+ [code] enables AST compression
(Python/JS/TS/Go/Rust/Java/C/C++/Perl). Adding [ml]{" "}
enables the Kompress-v2 HF model for prose/agentic traces but
adds ~1 GB (torch + huggingface-hub).
)}
-
+
Compress LLM output{" "}
@@ -589,6 +733,8 @@ export default function TokenSaverClient() {
/>
+ {/* PXPIPE hidden from UI — experimental, not exposed to users yet */}
+ {false && (
@@ -632,6 +778,7 @@ export default function TokenSaverClient() {
onChange={() => handlePxpipeEnabled(!pxpipeEnabled)}
/>
+ )}
setShowPxpipeModal(false)}
>
@@ -849,6 +996,20 @@ export default function TokenSaverClient() {
+
+
setExtrasConfirm(null)}
+ onConfirm={() => {
+ const fn = extrasConfirm?.onConfirm;
+ setExtrasConfirm(null);
+ fn?.();
+ }}
+ title={extrasConfirm?.title}
+ message={extrasConfirm?.message}
+ confirmText={extrasConfirm?.confirmText}
+ variant={extrasConfirm?.variant}
+ />
);
}
diff --git a/src/app/api/headroom/extras/route.js b/src/app/api/headroom/extras/route.js
index 4c1d7230..3dffaa5b 100644
--- a/src/app/api/headroom/extras/route.js
+++ b/src/app/api/headroom/extras/route.js
@@ -1,11 +1,15 @@
import { NextResponse } from "next/server";
import { findPython310, getInstalledHeadroomExtras, HEADROOM_COMPRESSION_EXTRAS } from "@/lib/headroom/detect";
-import { installHeadroomExtras } from "@/lib/headroom/process";
+import { installHeadroomExtras, uninstallHeadroomExtras, getInstallLogTail } from "@/lib/headroom/process";
export const dynamic = "force-dynamic";
-export async function GET() {
+export async function GET(req) {
try {
+ // `?log=1` returns the live install/uninstall log tail for progress polling.
+ if (new URL(req.url).searchParams.get("log") === "1") {
+ return NextResponse.json({ log: getInstallLogTail() });
+ }
const python = findPython310();
const status = getInstalledHeadroomExtras(python);
return NextResponse.json({
@@ -28,3 +32,15 @@ export async function POST(req) {
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
+
+export async function DELETE(req) {
+ try {
+ const body = await req.json().catch(() => ({}));
+ const requested = Array.isArray(body?.extras) ? body.extras : [];
+ const result = await uninstallHeadroomExtras(requested);
+ return NextResponse.json(result);
+ } catch (error) {
+ const status = error.code === "NO_PYTHON" || error.code === "INVALID_EXTRAS" ? 400 : 500;
+ return NextResponse.json({ error: error.message, code: error.code || null }, { status });
+ }
+}
diff --git a/src/app/api/headroom/restart/route.js b/src/app/api/headroom/restart/route.js
new file mode 100644
index 00000000..7d98e80f
--- /dev/null
+++ b/src/app/api/headroom/restart/route.js
@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server";
+import { getSettings } from "@/lib/localDb";
+import { restartHeadroomProxy } from "@/lib/headroom/process";
+import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect";
+
+export const dynamic = "force-dynamic";
+
+function parsePortFromUrl(url) {
+ try {
+ const u = new URL(url);
+ const p = parseInt(u.port, 10);
+ if (p > 0 && p < 65536) return p;
+ } catch { /* ignore, fall through to default */ }
+ return null;
+}
+
+export async function POST() {
+ try {
+ const settings = await getSettings();
+ const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
+ if (!isLoopbackHeadroomUrl(url)) {
+ return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 });
+ }
+ const port = parsePortFromUrl(url) || 8787;
+ const result = await restartHeadroomProxy({
+ port,
+ codeAware: settings.headroomCodeAware === true,
+ kompress: settings.headroomKompress !== false,
+ });
+ return NextResponse.json({ success: true, ...result });
+ } catch (error) {
+ const status = error.code === "NOT_INSTALLED" ? 400 : 500;
+ return NextResponse.json({ error: error.message, code: error.code || null }, { status });
+ }
+}
diff --git a/src/app/api/headroom/start/route.js b/src/app/api/headroom/start/route.js
index 56af456c..9be6d88d 100644
--- a/src/app/api/headroom/start/route.js
+++ b/src/app/api/headroom/start/route.js
@@ -22,7 +22,11 @@ export async function POST() {
return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 });
}
const port = parsePortFromUrl(url) || 8787;
- const result = await startHeadroomProxy({ port });
+ const result = await startHeadroomProxy({
+ port,
+ codeAware: settings.headroomCodeAware === true,
+ kompress: settings.headroomKompress !== false,
+ });
return NextResponse.json({ success: true, ...result });
} catch (error) {
const status = error.code === "NOT_INSTALLED" ? 400 : 500;
diff --git a/src/lib/headroom/detect.js b/src/lib/headroom/detect.js
index 91b245f7..0ba9e2b4 100644
--- a/src/lib/headroom/detect.js
+++ b/src/lib/headroom/detect.js
@@ -9,7 +9,7 @@ export const HEADROOM_COMPRESSION_EXTRAS = ["code", "ml"];
// Marker packages that each extra pulls in. Detected from `pip list --format=json`
// so one call can answer both the installed version and active extras.
-const EXTRA_MARKERS = {
+export const EXTRA_MARKERS = {
code: ["tree-sitter", "tree-sitter-language-pack"],
ml: ["torch", "huggingface-hub"],
};
@@ -64,11 +64,33 @@ export function findHeadroomBinary() {
}
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
-// On Windows, `python3` and `python` can point at different envs. Prefer the
-// first candidate that can also see the installed `headroom-ai` package so the
+// `python3`, `python3.13`, `python` can point at different envs on any OS. Prefer
+// the interpreter that can also see the installed `headroom-ai` package so the
// dashboard probes and install action operate on the same interpreter as the CLI.
+// Falls back to the first version-eligible candidate when headroom-ai is not yet
+// installed anywhere (needed for the initial install).
+// Interpreters to probe, most specific first: the python next to the headroom
+// binary (guaranteed to have headroom-ai), then full paths from EXTRA_BINS, then
+// bare names resolved via PATH.
+function pythonCandidates() {
+ const list = [];
+ const bin = findHeadroomBinary();
+ if (bin) {
+ const dir = path.dirname(bin);
+ const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3", "python3.13", "python"];
+ for (const n of names) list.push(path.join(dir, n));
+ }
+ for (const dir of EXTRA_BINS) {
+ if (!dir) continue;
+ for (const n of PYTHON_CANDIDATES) list.push(path.join(dir, IS_WIN ? `${n}.exe` : n));
+ }
+ list.push(...PYTHON_CANDIDATES);
+ return list;
+}
+
export function findPython310() {
- for (const candidate of PYTHON_CANDIDATES) {
+ let fallback = null;
+ for (const candidate of pythonCandidates()) {
try {
const ver = execSync(`${candidate} --version`, {
stdio: ["ignore", "pipe", "ignore"],
@@ -79,7 +101,7 @@ export function findPython310() {
if (!match) continue;
const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)];
if (!(major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1]))) continue;
- if (!IS_WIN) return candidate;
+ if (!fallback) fallback = candidate;
try {
execFileSync(candidate, ["-m", "pip", "show", "headroom-ai"], {
stdio: ["ignore", "pipe", "ignore"],
@@ -89,13 +111,13 @@ export function findPython310() {
});
return candidate;
} catch {
- // Keep scanning on Windows until an interpreter sees headroom-ai.
+ // Keep scanning until an interpreter that sees headroom-ai is found.
}
} catch {
// candidate not present, try next
}
}
- return null;
+ return fallback;
}
// Probe whether a Headroom proxy is reachable at the given URL by hitting /health.
diff --git a/src/lib/headroom/process.js b/src/lib/headroom/process.js
index a404ad4f..41720875 100644
--- a/src/lib/headroom/process.js
+++ b/src/lib/headroom/process.js
@@ -2,11 +2,12 @@ import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import { DATA_DIR } from "@/lib/dataDir.js";
-import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, getInstalledHeadroomExtras } from "./detect.js";
+import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, EXTRA_MARKERS, getInstalledHeadroomExtras } from "./detect.js";
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
+const INSTALL_LOG_FILE = path.join(HEADROOM_DIR, "install.log");
const DEFAULT_PORT = 8787;
const STARTUP_TIMEOUT_MS = 8000;
@@ -41,7 +42,17 @@ export function getManagedPid() {
return pid && isPidAlive(pid) ? pid : null;
}
-export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
+// Build proxy CLI flags for the active compression extras. `[code]` (AST
+// compression) is off by default in headroom → pass --code-aware to turn it on;
+// `[ml]` (Kompress) is on by default → pass --disable-kompress to turn it off.
+function extrasProxyArgs({ codeAware, kompress } = {}) {
+ const args = [];
+ if (codeAware) args.push("--code-aware");
+ if (kompress === false) args.push("--disable-kompress");
+ return args;
+}
+
+export async function startHeadroomProxy({ port = DEFAULT_PORT, codeAware = false, kompress = true } = {}) {
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
const binary = findHeadroomBinary();
if (!binary) {
@@ -57,7 +68,8 @@ export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
// spawn stdio requires fd numbers, not WriteStream objects.
const outFd = fs.openSync(LOG_FILE, "a");
- const child = spawn(binary, ["proxy", "--port", String(safePort)], {
+ const args = ["proxy", "--port", String(safePort), ...extrasProxyArgs({ codeAware, kompress })];
+ const child = spawn(binary, args, {
stdio: ["ignore", outFd, outFd],
detached: true,
windowsHide: true,
@@ -118,6 +130,25 @@ export function stopHeadroomProxy() {
}
}
+// Stop the managed proxy (if any), wait for the pid to die, then start again
+// with the given flags. Used when toggling active extras that require a restart.
+export async function restartHeadroomProxy(opts = {}) {
+ const pid = getManagedPid();
+ if (pid) {
+ try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
+ // Wait up to ~3s for graceful exit, force-kill if still alive.
+ for (let i = 0; i < 30 && isPidAlive(pid); i++) {
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ if (isPidAlive(pid)) {
+ try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
+ await new Promise((r) => setTimeout(r, 300));
+ }
+ clearPid();
+ }
+ return startHeadroomProxy(opts);
+}
+
export function getHeadroomLogTail(maxLines = 200) {
try {
if (!fs.existsSync(LOG_FILE)) return "";
@@ -153,7 +184,8 @@ export async function installHeadroomExtras(extras = []) {
const args = ["-m", "pip", "install", "--upgrade", spec];
ensureDir();
- const outFd = fs.openSync(path.join(HEADROOM_DIR, "install.log"), "a");
+ // Truncate ("w") so the log reflects only the current install for live progress.
+ const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
const child = spawn(py, args, {
stdio: ["ignore", outFd, outFd],
windowsHide: true,
@@ -175,3 +207,54 @@ export async function installHeadroomExtras(extras = []) {
});
});
}
+
+// Uninstall the marker packages that back a single extra (e.g. `ml` → torch,
+// huggingface-hub). `headroom-ai` base and the `proxy` extra are never removed.
+export async function uninstallHeadroomExtras(extras = []) {
+ const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
+ const py = findPython310();
+ if (!py) {
+ const err = new Error("Python >= 3.10 not found");
+ err.code = "NO_PYTHON";
+ throw err;
+ }
+ const pkgs = [...new Set(requested.flatMap((e) => EXTRA_MARKERS[e] || []))];
+ if (pkgs.length === 0) {
+ const err = new Error("No valid extras to remove");
+ err.code = "INVALID_EXTRAS";
+ throw err;
+ }
+ const args = ["-m", "pip", "uninstall", "-y", ...pkgs];
+
+ ensureDir();
+ const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
+ const child = spawn(py, args, {
+ stdio: ["ignore", outFd, outFd],
+ windowsHide: true,
+ env: { ...process.env },
+ });
+
+ return new Promise((resolve, reject) => {
+ child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
+ child.once("exit", (code) => {
+ fs.closeSync(outFd);
+ if (code === 0) {
+ const status = getInstalledHeadroomExtras(py);
+ resolve({ success: true, code, removed: pkgs, extras: requested, ...status });
+ } else {
+ const err = new Error(`pip uninstall exited with code=${code} — see headroom/install.log`);
+ err.code = "UNINSTALL_FAILED";
+ reject(err);
+ }
+ });
+ });
+}
+
+// Read the tail of the install/uninstall log for live progress in the UI.
+export function getInstallLogTail(maxLines = 15) {
+ try {
+ if (!fs.existsSync(INSTALL_LOG_FILE)) return "";
+ const lines = fs.readFileSync(INSTALL_LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean);
+ return lines.slice(-maxLines).join("\n");
+ } catch { return ""; }
+}
diff --git a/tests/unit/headroom-detect.test.js b/tests/unit/headroom-detect.test.js
index 1f0d3c30..d1239a6b 100644
--- a/tests/unit/headroom-detect.test.js
+++ b/tests/unit/headroom-detect.test.js
@@ -38,23 +38,22 @@ describe("headroom detect", () => {
});
it("prefers the interpreter that actually has headroom-ai installed", () => {
+ // headroom binary lives in a bin dir; the python next to it has headroom-ai.
+ const binPython = "/opt/hr/bin/python3";
mocks.execSync.mockImplementation((cmd) => {
- if (String(cmd).includes("where") || String(cmd).includes("which")) return Buffer.from("C:/Python/Scripts/headroom.exe\n");
- if (String(cmd).includes("python3 --version")) return Buffer.from("Python 3.13.0\n");
- if (String(cmd).includes("python --version")) return Buffer.from("Python 3.13.0\n");
+ if (String(cmd).includes("where") || String(cmd).includes("which")) return Buffer.from("/opt/hr/bin/headroom\n");
+ if (String(cmd).includes("--version")) return Buffer.from("Python 3.13.0\n");
throw new Error("unexpected execSync");
});
mocks.execFileSync.mockImplementation((py, args) => {
- if (py === "python3" && args.join(" ") === "-m pip show headroom-ai") throw new Error("not installed in python3");
- if (py === "python" && args.join(" ") === "-m pip show headroom-ai") return Buffer.from("Name: headroom-ai\nVersion: 0.26.0\n");
- if (py === "python" && args.join(" ").startsWith("-m pip list ")) return Buffer.from(JSON.stringify([
- { name: "headroom-ai", version: "0.26.0" },
- { name: "tree-sitter", version: "0.25.0" },
- ]));
+ if (args.join(" ") === "-m pip show headroom-ai") {
+ if (py === binPython) return Buffer.from("Name: headroom-ai\nVersion: 0.26.0\n");
+ throw new Error(`not installed in ${py}`);
+ }
throw new Error(`unexpected execFileSync: ${py} ${args.join(" ")}`);
});
- expect(findPython310()).toBe("python");
+ expect(findPython310()).toBe(binPython);
});
it("keeps top-level installed flag true when extras are readable", async () => {