diff --git a/src/app/api/headroom/extras/route.js b/src/app/api/headroom/extras/route.js
new file mode 100644
index 00000000..3dffaa5b
--- /dev/null
+++ b/src/app/api/headroom/extras/route.js
@@ -0,0 +1,46 @@
+import { NextResponse } from "next/server";
+import { findPython310, getInstalledHeadroomExtras, HEADROOM_COMPRESSION_EXTRAS } from "@/lib/headroom/detect";
+import { installHeadroomExtras, uninstallHeadroomExtras, getInstallLogTail } from "@/lib/headroom/process";
+
+export const dynamic = "force-dynamic";
+
+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({
+ available: HEADROOM_COMPRESSION_EXTRAS,
+ ...status,
+ });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
+
+export async function POST(req) {
+ try {
+ const body = await req.json().catch(() => ({}));
+ const requested = Array.isArray(body?.extras) ? body.extras : [];
+ const result = await installHeadroomExtras(requested);
+ return NextResponse.json(result);
+ } catch (error) {
+ const status = error.code === "NOT_INSTALLED" || error.code === "NO_PYTHON" ? 400 : 500;
+ 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/proxy/[...path]/route.js b/src/app/api/headroom/proxy/[...path]/route.js
new file mode 100644
index 00000000..dee647d1
--- /dev/null
+++ b/src/app/api/headroom/proxy/[...path]/route.js
@@ -0,0 +1,104 @@
+import { NextResponse } from "next/server";
+import { getSettings } from "@/lib/localDb";
+import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
+
+export const dynamic = "force-dynamic";
+
+const HOP_BY_HOP_HEADERS = new Set([
+ "connection",
+ "keep-alive",
+ "proxy-authenticate",
+ "proxy-authorization",
+ "te",
+ "trailer",
+ "transfer-encoding",
+ "upgrade",
+]);
+
+const DASHBOARD_PREFIX = "/api/headroom/proxy";
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
+
+async function getTargetBase() {
+ const settings = await getSettings();
+ const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
+ const target = new URL(url);
+ if (!["http:", "https:"].includes(target.protocol)) {
+ throw new Error("Headroom URL must use http or https");
+ }
+ return target;
+}
+
+function buildTargetUrl(base, path, search) {
+ const target = new URL(base);
+ target.pathname = `/${path.join("/")}`;
+ target.search = search;
+ return target;
+}
+
+function forwardedHeaders(request, target) {
+ const headers = new Headers(request.headers);
+ for (const header of headers.keys()) {
+ if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
+ }
+ headers.delete("host");
+ // Never leak viewer credentials to a non-loopback Headroom host
+ if (!LOOPBACK_HOSTS.has(target.hostname.replace(/^\[|\]$/g, "").toLowerCase())) {
+ headers.delete("cookie");
+ headers.delete("authorization");
+ }
+ return headers;
+}
+
+function rewriteDashboardHtml(html) {
+ return html.replace(
+ /fetch\('(?=\/(?:stats|health|stats-history|transformations\/feed))/g,
+ `fetch('${DASHBOARD_PREFIX}`,
+ );
+}
+
+async function proxy(request, { params }) {
+ try {
+ const base = await getTargetBase();
+ const { search } = new URL(request.url);
+ const path = (await params).path || [];
+ const target = buildTargetUrl(base, path, search);
+ const method = request.method;
+ const hasBody = !["GET", "HEAD"].includes(method);
+
+ const response = await fetch(target, {
+ method,
+ headers: forwardedHeaders(request, target),
+ body: hasBody ? request.body : undefined,
+ duplex: hasBody ? "half" : undefined,
+ redirect: "manual",
+ });
+
+ const headers = new Headers(response.headers);
+ for (const header of headers.keys()) {
+ if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
+ }
+
+ if (path.join("/") === "dashboard") {
+ const contentType = response.headers.get("content-type") || "";
+ if (contentType.includes("text/html")) {
+ headers.delete("content-length");
+ return new NextResponse(rewriteDashboardHtml(await response.text()), {
+ status: response.status,
+ headers,
+ });
+ }
+ }
+
+ return new NextResponse(response.body, { status: response.status, headers });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
+
+export const GET = proxy;
+export const POST = proxy;
+export const PUT = proxy;
+export const PATCH = proxy;
+export const DELETE = proxy;
+export const HEAD = proxy;
+export const OPTIONS = proxy;
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/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js
index ce7fdbc5..57bff272 100644
--- a/src/app/api/oauth/[provider]/[action]/route.js
+++ b/src/app/api/oauth/[provider]/[action]/route.js
@@ -150,8 +150,16 @@ export async function GET(request, { params }) {
}
: undefined;
- // Providers that don't use PKCE for device code
- const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
+ // Providers that don't use PKCE for device code (Grok CLI HAR: plain device_code, no challenge)
+ const noPkceDeviceProviders = [
+ "github",
+ "kiro",
+ "kimi-coding",
+ "kilocode",
+ "codebuddy-cn",
+ "qoder",
+ "grok-cli",
+ ];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js
index bc31306a..52f0291b 100644
--- a/src/app/api/providers/[id]/models/route.js
+++ b/src/app/api/providers/[id]/models/route.js
@@ -236,6 +236,7 @@ const PROVIDER_MODELS_CONFIG = {
xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"),
mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"),
perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
+ "perplexity-agent": createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"),
fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"),
cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"),
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index a58281d1..a05e6f4e 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -103,8 +103,61 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
+ // Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
+ "grok-cli": {
+ url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
+ method: "GET",
+ authHeader: "Authorization",
+ authPrefix: "Bearer ",
+ extraHeaders: {
+ Accept: "application/json",
+ ...(PROVIDERS["grok-cli"]?.headers || {
+ "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
+ "x-xai-token-auth": "xai-grok-cli",
+ "x-grok-client-identifier": "grok-pager",
+ "x-grok-client-version": "0.2.93",
+ }),
+ },
+ refreshable: true,
+ // Subscription spending-limit is not an auth failure — token is fine, credits aren't.
+ // Accept 402 so the connection stays "active" with a warning (same idea as Codex 400).
+ acceptStatuses: [402],
+ softFailMessage: {
+ 402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
+ },
+ },
};
+/**
+ * Classify an OAuth probe response as success / soft-success / hard-fail.
+ * Soft success (e.g. 402 spending-limit on Grok CLI) means auth works but the
+ * account cannot spend — keep connection active and surface a warning.
+ * Exported for unit tests.
+ */
+export function classifyOAuthProbeResult(res, config, bodyText = "") {
+ if (!res) return { valid: false, error: "No response", soft: false };
+ const status = res.status;
+ const accepted = res.ok || (config?.acceptStatuses && config.acceptStatuses.includes(status));
+ if (!accepted) {
+ if (status === 401) return { valid: false, error: "Token invalid or revoked", soft: false };
+ if (status === 403) return { valid: false, error: "Access denied", soft: false };
+ return { valid: false, error: `API returned ${status}`, soft: false };
+ }
+
+ // Soft success only when the provider configured an explicit message for this
+ // status (e.g. Grok CLI 402 spending-limit). Codex-style acceptStatuses:[400]
+ // stays silent success — 400 there only proves auth, not a user-facing warning.
+ if (!res.ok && config?.acceptStatuses?.includes(status)) {
+ const softMap = config.softFailMessage || {};
+ if (softMap[status]) {
+ return { valid: true, error: softMap[status], soft: true };
+ }
+ return { valid: true, error: null, soft: false };
+ }
+
+ return { valid: true, error: null, soft: false };
+}
+
async function probeClineAccessToken(accessToken) {
const res = await fetch("https://api.cline.bot/api/v1/users/me", {
method: "GET",
@@ -186,7 +239,7 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
- if (provider === "codex") {
+ if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
return await refreshProviderCredentials(provider, connection, console);
}
@@ -362,9 +415,19 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const fetchOpts = { method: config.method, headers };
if (config.body) fetchOpts.body = config.body;
const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy);
+ const bodyText = !res.ok ? await res.text().catch(() => "") : "";
- const accepted = res.ok || (config.acceptStatuses && config.acceptStatuses.includes(res.status));
- if (accepted) return { valid: true, error: null, refreshed, newTokens };
+ const classified = classifyOAuthProbeResult(res, config, bodyText);
+ if (classified.valid) {
+ return {
+ valid: true,
+ // soft success surfaces warning text without marking connection error
+ error: classified.soft ? classified.error : null,
+ warning: classified.soft ? classified.error : null,
+ refreshed,
+ newTokens,
+ };
+ }
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
@@ -376,15 +439,22 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const retryOpts = { method: config.method, headers: retryHeaders };
if (config.body) retryOpts.body = config.body;
const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy);
- const retryAccepted = retryRes.ok || (config.acceptStatuses && config.acceptStatuses.includes(retryRes.status));
- if (retryAccepted) return { valid: true, error: null, refreshed: true, newTokens: tokens };
+ const retryBody = !retryRes.ok ? await retryRes.text().catch(() => "") : "";
+ const retryClassified = classifyOAuthProbeResult(retryRes, config, retryBody);
+ if (retryClassified.valid) {
+ return {
+ valid: true,
+ error: retryClassified.soft ? retryClassified.error : null,
+ warning: retryClassified.soft ? retryClassified.error : null,
+ refreshed: true,
+ newTokens: tokens,
+ };
+ }
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
- if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed };
- if (res.status === 403) return { valid: false, error: "Access denied", refreshed };
- return { valid: false, error: `API returned ${res.status}`, refreshed };
+ return { valid: false, error: classified.error, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
@@ -752,10 +822,18 @@ export async function testSingleConnection(id) {
const latencyMs = Date.now() - start;
+ // Soft success (e.g. Grok CLI 402 spending-limit): credentials are good, account is
+ // out of credits. Keep testStatus active; surface the message as lastError so the
+ // dashboard can show a warning without marking the connection broken.
+ const softWarning = result.valid && (result.warning || result.error);
const updateData = {
testStatus: result.valid ? "active" : "error",
- lastError: result.valid ? null : result.error,
- lastErrorAt: result.valid ? null : new Date().toISOString(),
+ lastError: result.valid ? (softWarning || null) : result.error,
+ lastErrorAt: result.valid
+ ? softWarning
+ ? new Date().toISOString()
+ : null
+ : new Date().toISOString(),
};
if (result.refreshed && result.newTokens) {
diff --git a/src/app/api/pxpipe/health/route.js b/src/app/api/pxpipe/health/route.js
new file mode 100644
index 00000000..f974342b
--- /dev/null
+++ b/src/app/api/pxpipe/health/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { runHealthCheck } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+
+export async function POST() {
+ try {
+ const result = await runHealthCheck();
+ return NextResponse.json(result);
+ } catch (error) {
+ return NextResponse.json({ healthy: false, checks: [], error: error.message }, { status: 500 });
+ }
+}
+
+// GET mirrors POST so the card can probe on page load without a mutation call.
+export const GET = POST;
diff --git a/src/app/api/pxpipe/install/route.js b/src/app/api/pxpipe/install/route.js
new file mode 100644
index 00000000..c5eedcc0
--- /dev/null
+++ b/src/app/api/pxpipe/install/route.js
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { installPxpipe } from "@/lib/pxpipe/install.js";
+import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
+import { runHealthCheck } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+// npm install can legitimately take minutes on a cold cache.
+export const maxDuration = 300;
+
+// Install (or repair — same operation, reinstalls @latest) then re-run the health check.
+export async function POST() {
+ try {
+ const info = await installPxpipe();
+ unloadPxpipe(); // drop any previously-loaded version so health loads the fresh one
+ const health = await runHealthCheck();
+ return NextResponse.json({ ...info, health });
+ } catch (error) {
+ return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/logs/route.js b/src/app/api/pxpipe/logs/route.js
new file mode 100644
index 00000000..051db386
--- /dev/null
+++ b/src/app/api/pxpipe/logs/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from "next/server";
+import { getInstallLogTail } from "@/lib/pxpipe/install.js";
+import { readPxpipeEvents } from "@/lib/pxpipe/events.js";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(Number(searchParams.get("limit")) || 100, 500);
+ return NextResponse.json({
+ installLog: getInstallLogTail(),
+ events: readPxpipeEvents({ limit }).reverse(),
+ });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/restart/route.js b/src/app/api/pxpipe/restart/route.js
new file mode 100644
index 00000000..1aaab39a
--- /dev/null
+++ b/src/app/api/pxpipe/restart/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { unloadPxpipe, loadPxpipe } from "@/lib/pxpipe/loader.js";
+import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+
+// Reload the in-process module (picks up an upgraded install without a server restart).
+export async function POST() {
+ try {
+ unloadPxpipe();
+ await loadPxpipe();
+ return NextResponse.json(getPxpipeStatus());
+ } catch (error) {
+ return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/start/route.js b/src/app/api/pxpipe/start/route.js
new file mode 100644
index 00000000..5dad8b2b
--- /dev/null
+++ b/src/app/api/pxpipe/start/route.js
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+import { getSettings } from "@/lib/localDb";
+import { getInstallInfo, installPxpipe } from "@/lib/pxpipe/install.js";
+import { loadPxpipe } from "@/lib/pxpipe/loader.js";
+import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+// "Start" in library mode = warm the in-process transform module.
+// Auto-installs first when the package is missing and pxpipeAutoInstall is on.
+export async function POST() {
+ try {
+ if (!getInstallInfo().installed) {
+ const settings = await getSettings();
+ if (!settings.pxpipeAutoInstall) {
+ return NextResponse.json({ error: "PXPIPE is not installed", code: "NOT_INSTALLED" }, { status: 409 });
+ }
+ await installPxpipe();
+ }
+ await loadPxpipe();
+ return NextResponse.json(getPxpipeStatus());
+ } catch (error) {
+ return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/stats/route.js b/src/app/api/pxpipe/stats/route.js
new file mode 100644
index 00000000..c5860b6e
--- /dev/null
+++ b/src/app/api/pxpipe/stats/route.js
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { getPxpipeStats } from "@/lib/pxpipe/events.js";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const recentLimit = Math.min(Number(searchParams.get("limit")) || 100, 500);
+ return NextResponse.json(getPxpipeStats({ recentLimit }));
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/status/route.js b/src/app/api/pxpipe/status/route.js
new file mode 100644
index 00000000..663c8398
--- /dev/null
+++ b/src/app/api/pxpipe/status/route.js
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { getSettings } from "@/lib/localDb";
+import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ const settings = await getSettings();
+ const status = getPxpipeStatus();
+ return NextResponse.json({
+ ...status,
+ enabled: !!settings.pxpipeEnabled,
+ autoInstall: !!settings.pxpipeAutoInstall,
+ minChars: settings.pxpipeMinChars,
+ timeoutMs: settings.pxpipeTimeoutMs,
+ });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/pxpipe/stop/route.js b/src/app/api/pxpipe/stop/route.js
new file mode 100644
index 00000000..cc675c52
--- /dev/null
+++ b/src/app/api/pxpipe/stop/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
+import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
+
+export const dynamic = "force-dynamic";
+
+// "Stop" in library mode = drop the in-process module; requests fail open to
+// uncompressed passthrough until it is started again.
+export async function POST() {
+ try {
+ const wasLoaded = unloadPxpipe();
+ return NextResponse.json({ stopped: wasLoaded, ...getPxpipeStatus() });
+ } catch (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/src/app/api/tunnel/status/route.js b/src/app/api/tunnel/status/route.js
index c94df58d..8b8f8053 100644
--- a/src/app/api/tunnel/status/route.js
+++ b/src/app/api/tunnel/status/route.js
@@ -1,11 +1,23 @@
import { NextResponse } from "next/server";
import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
+const STATUS_CACHE_TTL_MS = 3000; // coalesce rapid polls; underlying probes already cache 10s
+
+// Survive hot reload; one cache per process. Only tunnel/tailscale probes are cached —
+// download progress stays live so the enable/download UI updates smoothly.
+const statusCache = (global.__tunnelStatusCache ??= { value: null, fetchedAt: 0 });
+
export async function GET() {
try {
- const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
+ let probes = statusCache.value;
+ if (!probes || Date.now() - statusCache.fetchedAt >= STATUS_CACHE_TTL_MS) {
+ const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
+ probes = { tunnel, tailscale };
+ statusCache.value = probes;
+ statusCache.fetchedAt = Date.now();
+ }
const download = getDownloadStatus();
- return NextResponse.json({ tunnel, tailscale, download });
+ return NextResponse.json({ ...probes, download });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
diff --git a/src/app/api/usage/providers/route.js b/src/app/api/usage/providers/route.js
index 8a33fb33..8523b8ba 100644
--- a/src/app/api/usage/providers/route.js
+++ b/src/app/api/usage/providers/route.js
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
-import { getRequestDetails } from "@/lib/requestDetailsDb";
+import { getDistinctProviders } from "@/lib/requestDetailsDb";
import { getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
@@ -9,10 +9,9 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
*/
export async function GET() {
try {
- const { details } = await getRequestDetails({ pageSize: 9999 });
-
- // Extract unique providers
- const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort();
+ // Query DISTINCT provider column directly — avoids parsing every row's
+ // full JSON blob (can be hundreds of MB), which previously caused OOM.
+ const providerIds = await getDistinctProviders();
const providerNodes = await getProviderNodes();
const nodeMap = {};
diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js
index 73a3ceb2..9b154497 100644
--- a/src/app/api/usage/request-details/route.js
+++ b/src/app/api/usage/request-details/route.js
@@ -9,8 +9,10 @@ export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
- const page = parseInt(searchParams.get("page")) || 1;
- const pageSize = parseInt(searchParams.get("pageSize")) || 20;
+ const pageRaw = parseInt(searchParams.get("page"));
+ const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
+ const pageSizeRaw = parseInt(searchParams.get("pageSize"));
+ const pageSize = Number.isNaN(pageSizeRaw) ? 20 : pageSizeRaw;
const provider = searchParams.get("provider");
const model = searchParams.get("model");
const connectionId = searchParams.get("connectionId");
diff --git a/src/app/api/v1/messages/count_tokens/route.js b/src/app/api/v1/messages/count_tokens/route.js
index c5a2918f..2e08cd09 100644
--- a/src/app/api/v1/messages/count_tokens/route.js
+++ b/src/app/api/v1/messages/count_tokens/route.js
@@ -11,6 +11,64 @@ export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
+function countValueChars(value) {
+ if (value == null) return 0;
+ if (typeof value === "string") return value.length;
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value).length;
+ }
+ if (Array.isArray(value)) {
+ return value.reduce((total, item) => total + countValueChars(item), 0);
+ }
+ if (typeof value === "object") {
+ return Object.entries(value).reduce((total, [key, item]) => {
+ return total + key.length + countValueChars(item);
+ }, 0);
+ }
+ return 0;
+}
+
+function countContentBlockChars(block) {
+ if (block == null) return 0;
+ if (typeof block === "string") return block.length;
+ if (typeof block !== "object") return countValueChars(block);
+
+ switch (block.type) {
+ case "text":
+ return countValueChars(block.text);
+ case "tool_use":
+ return countValueChars(block.name) + countValueChars(block.input);
+ case "tool_result":
+ return countValueChars(block.content);
+ case "thinking":
+ return countValueChars(block.thinking);
+ default:
+ return countValueChars(block);
+ }
+}
+
+function countMessageChars(message) {
+ if (!message || typeof message !== "object") return 0;
+ const content = message.content;
+
+ if (typeof content === "string") return content.length;
+ if (Array.isArray(content)) {
+ return content.reduce((total, block) => total + countContentBlockChars(block), 0);
+ }
+ return countValueChars(content);
+}
+
+export function estimateAnthropicInputTokens(body = {}) {
+ const messages = Array.isArray(body.messages) ? body.messages : [];
+ let totalChars = countValueChars(body.system) + countValueChars(body.tools);
+
+ for (const msg of messages) {
+ totalChars += countMessageChars(msg);
+ }
+
+ return Math.ceil(totalChars / 4);
+}
+
/**
* POST /v1/messages/count_tokens - Mock token count response
*/
@@ -25,23 +83,7 @@ export async function POST(request) {
});
}
- // Estimate token count based on content length
- const messages = body.messages || [];
- let totalChars = 0;
- for (const msg of messages) {
- if (typeof msg.content === "string") {
- totalChars += msg.content.length;
- } else if (Array.isArray(msg.content)) {
- for (const part of msg.content) {
- if (part.type === "text" && part.text) {
- totalChars += part.text.length;
- }
- }
- }
- }
-
- // Rough estimate: ~4 chars per token
- const inputTokens = Math.ceil(totalChars / 4);
+ const inputTokens = estimateAnthropicInputTokens(body);
return new Response(JSON.stringify({
input_tokens: inputTokens
diff --git a/src/app/api/version/route.js b/src/app/api/version/route.js
index 7e7f4a80..5635f9ae 100644
--- a/src/app/api/version/route.js
+++ b/src/app/api/version/route.js
@@ -2,6 +2,10 @@ import https from "https";
import pkg from "../../../../package.json" with { type: "json" };
const NPM_PACKAGE_NAME = "9router";
+const VERSION_CACHE_TTL_MS = 3600000; // cache npm latest lookup for 1h
+
+// Survive hot reload; one cache per process
+const versionCache = (global.__npmVersionCache ??= { value: null, fetchedAt: 0 });
// Fetch latest version from npm registry
function fetchLatestVersion() {
@@ -36,8 +40,20 @@ function compareVersions(a, b) {
return 0;
}
+async function getLatestVersionCached() {
+ if (versionCache.value && Date.now() - versionCache.fetchedAt < VERSION_CACHE_TTL_MS) {
+ return versionCache.value;
+ }
+ const latest = await fetchLatestVersion();
+ if (latest) {
+ versionCache.value = latest;
+ versionCache.fetchedAt = Date.now();
+ }
+ return latest;
+}
+
export async function GET() {
- const latestVersion = await fetchLatestVersion();
+ const latestVersion = await getLatestVersionCached();
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;
diff --git a/src/app/globals.css b/src/app/globals.css
index d96cac7e..fdc219ba 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,4 +1,5 @@
-@import "tailwindcss";
+/* source() sets scan base to src/ for both webpack + Turbopack; auto-detection still skips binaries + gitignore */
+@import "tailwindcss" source("../../");
@custom-variant dark (&:where(.dark, .dark *));
diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js
index 83e4364e..3f90c4f8 100644
--- a/src/dashboardGuard.js
+++ b/src/dashboardGuard.js
@@ -81,6 +81,7 @@ const LOCAL_ONLY_PATHS = [
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",
+ "/api/headroom/proxy",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
diff --git a/src/i18n/config.js b/src/i18n/config.js
index 60a0c16c..ef6d41cb 100644
--- a/src/i18n/config.js
+++ b/src/i18n/config.js
@@ -1,41 +1,77 @@
-export const LOCALES = ["en", "vi", "zh-CN", "zh-TW", "ja", "pt-BR", "pt-PT", "ko", "es", "de", "fr", "he", "ar", "ru", "pl", "cs", "nl", "tr", "uk", "tl", "id", "th", "hi", "bn", "ur", "ro", "sv", "it", "el", "hu", "fi", "da", "no"];
+export const LOCALES = [
+ "en",
+ "vi",
+ "zh-CN",
+ "zh-TW",
+ "ja",
+ "pt-BR",
+ "pt-PT",
+ "ko",
+ "es",
+ "de",
+ "fr",
+ "he",
+ "ar",
+ "ru",
+ "pl",
+ "cs",
+ "nl",
+ "tr",
+ "uk",
+ "tl",
+ "id",
+ "th",
+ "hi",
+ "bn",
+ "ur",
+ "ro",
+ "sv",
+ "it",
+ "el",
+ "hu",
+ "fi",
+ "da",
+ "no",
+ "fa",
+];
export const DEFAULT_LOCALE = "en";
export const LOCALE_COOKIE = "locale";
export const LOCALE_NAMES = {
- "en": "English",
- "vi": "Tiếng Việt",
+ en: "English",
+ vi: "Tiếng Việt",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
- "ja": "日本語",
+ ja: "日本語",
"pt-BR": "Português (Brasil)",
"pt-PT": "Português (Portugal)",
- "ko": "한국어",
- "es": "Español",
- "de": "Deutsch",
- "fr": "Français",
- "he": "עברית",
- "ar": "العربية",
- "ru": "Русский",
- "pl": "Polski",
- "cs": "Čeština",
- "nl": "Nederlands",
- "tr": "Türkçe",
- "uk": "Українська",
- "tl": "Tagalog",
- "id": "Indonesia",
- "th": "ไทย",
- "hi": "हिन्दी",
- "bn": "বাংলা",
- "ur": "اردو",
- "ro": "Română",
- "sv": "Svenska",
- "it": "Italiano",
- "el": "Ελληνικά",
- "hu": "Magyar",
- "fi": "Suomi",
- "da": "Dansk",
- "no": "Norsk"
+ ko: "한국어",
+ es: "Español",
+ de: "Deutsch",
+ fr: "Français",
+ he: "עברית",
+ ar: "العربية",
+ ru: "Русский",
+ pl: "Polski",
+ cs: "Čeština",
+ nl: "Nederlands",
+ tr: "Türkçe",
+ uk: "Українська",
+ tl: "Tagalog",
+ id: "Indonesia",
+ th: "ไทย",
+ hi: "हिन्दी",
+ bn: "বাংলা",
+ ur: "اردو",
+ ro: "Română",
+ sv: "Svenska",
+ it: "Italiano",
+ el: "Ελληνικά",
+ hu: "Magyar",
+ fi: "Suomi",
+ da: "Dansk",
+ no: "Norsk",
+ fa: "فارسی",
};
export function normalizeLocale(locale) {
@@ -138,6 +174,9 @@ export function normalizeLocale(locale) {
if (locale === "no") {
return "no";
}
+ if (locale === "fa") {
+ return "fa";
+ }
return DEFAULT_LOCALE;
}
diff --git a/src/lib/db/backup.js b/src/lib/db/backup.js
index 39b853eb..f3a7ed15 100644
--- a/src/lib/db/backup.js
+++ b/src/lib/db/backup.js
@@ -1,9 +1,20 @@
+// DB safety backups — taken ONLY before a schema change (see migrate.js).
+//
+// ⚠️ AGENT/DEV NOTES:
+// - Backups are a best-effort safety net before schema migrations. There is NO
+// automated restore path; recovery is manual (copy a backup file back).
+// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
+// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
+// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
import fs from "node:fs";
import path from "node:path";
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
import { timestampSlug, getAppVersion } from "./version.js";
-const KEEP_BACKUPS = 5;
+const KEEP_BACKUPS = 3;
+
+// Tables excluded from safety backups (large, non-critical, reproducible).
+const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
export function makeBackupDir(label) {
ensureDirs();
@@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) {
return dest;
}
+// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
+// table EXCEPT the excluded ones into it. Avoids duplicating the huge
+// observability log, so the backup stays small regardless of DB size.
+export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
+ const dest = path.join(destDir, destName);
+ try { fs.rmSync(dest, { force: true }); } catch {}
+ const escaped = dest.replace(/'/g, "''");
+
+ adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
+ try {
+ const excluded = new Set(BACKUP_EXCLUDE_TABLES);
+ const tables = adapter
+ .all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
+ .filter((t) => !excluded.has(t.name));
+
+ adapter.transaction(() => {
+ for (const t of tables) {
+ // Recreate table structure in backup DB, then copy rows.
+ const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
+ adapter.exec(createSql);
+ adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
+ }
+ });
+ } finally {
+ try { adapter.exec("DETACH DATABASE bak"); } catch {}
+ }
+ return dest;
+}
+
export function pruneOldBackups() {
if (!fs.existsSync(BACKUPS_DIR)) return;
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })
diff --git a/src/lib/db/index.js b/src/lib/db/index.js
index 0d5dd652..e1c68472 100644
--- a/src/lib/db/index.js
+++ b/src/lib/db/index.js
@@ -64,7 +64,7 @@ export {
// Request details
export {
- saveRequestDetail, getRequestDetails, getRequestDetailById,
+ saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "./repos/requestDetailsRepo.js";
// Export/import full DB
diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js
index 36183c90..0cca4da0 100644
--- a/src/lib/db/migrate.js
+++ b/src/lib/db/migrate.js
@@ -1,10 +1,10 @@
import fs from "node:fs";
import path from "node:path";
-import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js";
-import { TABLES, buildCreateTableSql } from "./schema.js";
+import { LEGACY_FILES, DB_DIR } from "./paths.js";
+import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js";
import { MIGRATIONS, latestVersion } from "./migrations/index.js";
import { getMetaSync, setMetaSync } from "./helpers/metaStore.js";
-import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js";
+import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js";
import { getAppVersion } from "./version.js";
import { stringifyJson } from "./helpers/jsonCol.js";
@@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) {
// a brand-new DB as non-fresh once schemaVersion is written).
const fresh = isFreshDb(adapter);
+ // Prune stale backups every boot so old oversized backups shrink to KEEP.
+ pruneOldBackups();
+
+ // Bootstrap _meta so we can read the stored backup schema version below
+ // (runVersionedMigrations also ensures this, but we need it earlier here).
+ adapter.exec(buildCreateTableSql("_meta", TABLES._meta));
+
+ // Detect a pending schema change via the central SCHEMA_VERSION const.
+ // A lightweight backup is taken BEFORE any schema mutation below.
+ const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0;
+ const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION;
+ if (schemaChanging) {
+ try {
+ const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`);
+ backupDbLite(adapter, backupDir);
+ pruneOldBackups();
+ console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer} → ${SCHEMA_VERSION}: ${backupDir}`);
+ } catch (e) {
+ console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`);
+ }
+ }
+
// 1. Always run versioned migrations chain (skip-version safe)
const migInfo = runVersionedMigrations(adapter);
// 2. Additive sync (auto add missing columns/indexes declared in TABLES)
syncSchemaFromTables(adapter);
+ // Stamp the schema version we just reached so future boots skip re-backup.
+ setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
+
// 3. One-time legacy JSON import (only if DB was fresh on entry)
const alreadyImported = fs.existsSync(MIGRATED_MARKER);
const legacyMain = readJsonSafe(LEGACY_FILES.main);
@@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) {
importLegacyDisabled(adapter, legacyDisabled);
importLegacyDetails(adapter, legacyDetails);
setMetaSync(adapter, "appVersion", getAppVersion());
+ setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
setMetaSync(adapter, "migratedAt", new Date().toISOString());
});
} catch (err) {
@@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) {
return;
}
- if (fresh) {
- setMetaSync(adapter, "appVersion", getAppVersion());
- return;
- }
-
- // 4. App version bump → backup data.sqlite (safety net before user-side upgrade)
- const oldVer = getMetaSync(adapter, "appVersion", null);
+ // Track app version for informational purposes only. App version bumps no
+ // longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do.
const newVer = getAppVersion();
- if (oldVer && oldVer !== newVer) {
- const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`);
- try { backupFile(DATA_FILE, backupDir); } catch {}
- setMetaSync(adapter, "appVersion", newVer);
- pruneOldBackups();
- console.log(`[DB][migrate] App ${oldVer} → ${newVer} | schema ${migInfo.from} → ${migInfo.to} | backup: ${backupDir}`);
- } else if (migInfo.applied > 0) {
- // Schema upgrade without app version bump — still backup
- const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`);
- try { backupFile(DATA_FILE, backupDir); } catch {}
- pruneOldBackups();
- }
+ const oldVer = getMetaSync(adapter, "appVersion", null);
+ if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer);
}
diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js
index 6075f234..4181843f 100644
--- a/src/lib/db/repos/connectionsRepo.js
+++ b/src/lib/db/repos/connectionsRepo.js
@@ -56,6 +56,17 @@ function upsert(db, c) {
);
}
+function deriveConnectionName(data, fallbackName) {
+ if (data.provider === "github") {
+ return data.providerSpecificData?.githubLogin
+ || data.providerSpecificData?.githubEmail
+ || data.email
+ || data.providerSpecificData?.githubName
+ || fallbackName;
+ }
+ return fallbackName;
+}
+
export async function getProviderConnections(filter = {}) {
const db = await getAdapter();
const where = [];
@@ -102,7 +113,18 @@ export async function createProviderConnection(data) {
const incomingWs = data.providerSpecificData?.chatgptAccountId;
existing = all.find(c => {
if (c.authType !== "oauth" || c.email !== data.email) return false;
- // Workspace providers (Codex) use workspace ID when both sides have it
+
+ // Codex/OpenAI can issue multiple OAuth grants for the same email.
+ // Refresh tokens are rotated single-use; collapsing a new login onto an
+ // existing bare-email row overwrites the first account's token pair and
+ // makes it look "invalid" after adding a second account. Only update an
+ // existing Codex row when both rows expose the same ChatGPT account ID.
+ if (data.provider === "codex") {
+ const existingWs = c.providerSpecificData?.chatgptAccountId;
+ return !!incomingWs && !!existingWs && incomingWs === existingWs;
+ }
+
+ // Workspace providers use workspace ID when both sides have it
const existingWs = c.providerSpecificData?.chatgptAccountId;
if (incomingWs && existingWs) return incomingWs === existingWs;
if (incomingWs && !existingWs) return false;
@@ -133,7 +155,7 @@ export async function createProviderConnection(data) {
let connectionName = data.name || null;
if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) {
- connectionName = data.email || `Account ${all.length + 1}`;
+ connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`);
}
let connectionPriority = data.priority;
if (!connectionPriority) {
diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js
index 813974ae..defd294b 100644
--- a/src/lib/db/repos/requestDetailsRepo.js
+++ b/src/lib/db/repos/requestDetailsRepo.js
@@ -98,6 +98,7 @@ async function flushToDatabase() {
providerRequest: truncateField(item.providerRequest, config.maxJsonSize),
providerResponse: truncateField(item.providerResponse, config.maxJsonSize),
response: truncateField(item.response, config.maxJsonSize),
+ pxpipe: item.pxpipe || undefined,
};
db.run(
@@ -174,6 +175,12 @@ export async function getRequestDetails(filter = {}) {
};
}
+export async function getDistinctProviders() {
+ const db = await getAdapter();
+ const rows = db.all(`SELECT DISTINCT provider FROM requestDetails WHERE provider IS NOT NULL ORDER BY provider ASC`);
+ return rows.map((r) => r.provider);
+}
+
export async function getRequestDetailById(id) {
const db = await getAdapter();
const row = db.get(`SELECT data FROM requestDetails WHERE id = ?`, [id]);
diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js
index 60a5054b..0f53eecf 100644
--- a/src/lib/db/repos/settingsRepo.js
+++ b/src/lib/db/repos/settingsRepo.js
@@ -43,6 +43,10 @@ const DEFAULT_SETTINGS = {
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
+ pxpipeEnabled: false,
+ pxpipeAutoInstall: true,
+ pxpipeMinChars: 25000,
+ pxpipeTimeoutMs: 15000,
};
async function readRaw() {
diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js
index ce6c4761..6b1966cb 100644
--- a/src/lib/db/repos/usageRepo.js
+++ b/src/lib/db/repos/usageRepo.js
@@ -189,8 +189,7 @@ export function trackPendingRequest(model, provider, connectionId, started, erro
lastErrorProvider.ts = Date.now();
}
- const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
- console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`);
+ // [PENDING] console line removed; lifecycle is visible via "▶" and "📊 done" lines
scheduleStatsEvent("pending");
}
diff --git a/src/lib/db/schema.js b/src/lib/db/schema.js
index 71c230c8..099386c2 100644
--- a/src/lib/db/schema.js
+++ b/src/lib/db/schema.js
@@ -1,4 +1,8 @@
-// Latest schema version — bumped when a migration is added in ./migrations/
+// ⚠️ AGENT/DEV: Bump this by +1 EVERY TIME you change the schema below
+// (add/remove/alter a table, column, or index in TABLES). It drives the
+// pre-change safety backup in migrate.js: when the stored version is lower,
+// one lightweight DB backup is taken before applying schema changes. Forgetting
+// to bump only skips that backup — it does NOT break the additive auto-sync.
export const SCHEMA_VERSION = 1;
export const PRAGMA_SQL = `
diff --git a/src/lib/headroom/detect.js b/src/lib/headroom/detect.js
index ac64dc87..0ba9e2b4 100644
--- a/src/lib/headroom/detect.js
+++ b/src/lib/headroom/detect.js
@@ -1,6 +1,21 @@
-import { execSync } from "child_process";
+import { execFileSync, execSync } from "child_process";
import path from "path";
+// Extras that improve headroom compression quality. `proxy` is the base;
+// `code` adds tree-sitter AST compression; `ml` adds Kompress-v2 HF model.
+// Other `[all]` extras (image, voice, otel, reports, evals, ...) are not
+// useful for the 9router proxy use case, so we don't track them here.
+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.
+export const EXTRA_MARKERS = {
+ code: ["tree-sitter", "tree-sitter-language-pack"],
+ ml: ["torch", "huggingface-hub"],
+};
+
+const HEADROOM_PIP_TIMEOUT_MS = 8000;
+
const IS_WIN = process.platform === "win32";
const WHICH_CMD = IS_WIN ? "where" : "which";
@@ -49,8 +64,33 @@ export function findHeadroomBinary() {
}
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
+// `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"],
@@ -60,14 +100,24 @@ export function findPython310() {
const match = ver.match(/(\d+)\.(\d+)/);
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])) {
+ if (!(major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1]))) continue;
+ if (!fallback) fallback = candidate;
+ try {
+ execFileSync(candidate, ["-m", "pip", "show", "headroom-ai"], {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ timeout: HEADROOM_PIP_TIMEOUT_MS,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ });
return candidate;
+ } catch {
+ // 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.
@@ -98,5 +148,45 @@ export async function getHeadroomStatus(url) {
const installed = Boolean(path);
const running = await probeProxyRunning(url);
const localUrl = isLoopbackHeadroomUrl(url);
- return { installed, path, running, python, localUrl, canStart: installed && localUrl };
+ const extrasStatus = installed ? getInstalledHeadroomExtras(python) : { installed: false, version: null, extras: { code: false, ml: false } };
+ return {
+ installed,
+ path,
+ running,
+ python,
+ localUrl,
+ canStart: installed && localUrl,
+ version: extrasStatus.version,
+ extras: extrasStatus.extras,
+ };
+}
+
+// Parse installed headroom-ai version + which compression extras are
+// actually installed (detected via marker package presence). One `pip list`
+// call is enough to answer both questions.
+//
+// Returns: { installed: bool, version: string|null, extras: { code, ml } }
+export function getInstalledHeadroomExtras(python) {
+ const py = python || findPython310();
+ if (!py) return { installed: false, version: null, extras: { code: false, ml: false } };
+ try {
+ const out = execFileSync(py, ["-m", "pip", "list", "--format=json", "--disable-pip-version-check"], {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ timeout: HEADROOM_PIP_TIMEOUT_MS,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ }).toString();
+ const packages = JSON.parse(out);
+ const names = new Set(packages.map((p) => String(p.name || "").toLowerCase()));
+ const installed = names.has("headroom-ai");
+ if (!installed) return { installed: false, version: null, extras: { code: false, ml: false } };
+ const version = packages.find((p) => p.name?.toLowerCase() === "headroom-ai")?.version || null;
+ const extras = {};
+ for (const extra of HEADROOM_COMPRESSION_EXTRAS) {
+ extras[extra] = EXTRA_MARKERS[extra].some((m) => names.has(m));
+ }
+ return { installed: true, version, extras };
+ } catch {
+ return { installed: false, version: null, extras: { code: false, ml: false } };
+ }
}
diff --git a/src/lib/headroom/process.js b/src/lib/headroom/process.js
index d50bc7ec..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 } 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 "";
@@ -126,3 +157,104 @@ export function getHeadroomLogTail(maxLines = 200) {
return lines.slice(-maxLines).join("\n");
} catch { return ""; }
}
+
+// Install (or upgrade) headroom-ai with the requested compression extras.
+// `extras` is a whitelist from HEADROOM_COMPRESSION_EXTRAS — anything else
+// is rejected to keep the install surface predictable. Always installs the
+// `proxy` base + whatever extras the user picked, regardless of what is
+// already present.
+export async function installHeadroomExtras(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;
+ }
+ if (!findHeadroomBinary()) {
+ const err = new Error("headroom-ai not installed (run `pip install headroom-ai[proxy]` first)");
+ err.code = "NOT_INSTALLED";
+ throw err;
+ }
+ // pip install string is built from a closed set (HEADROOM_COMPRESSION_EXTRAS),
+ // so it cannot be poisoned by caller input — the comma-list is a fixed
+ // ['proxy', ...requested]. No shell interpolation.
+ const extrasList = ["proxy", ...requested].join(",");
+ const spec = `headroom-ai[${extrasList}]`;
+ const args = ["-m", "pip", "install", "--upgrade", spec];
+
+ ensureDir();
+ // 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,
+ 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, spec, extras: requested, ...status });
+ } else {
+ const err = new Error(`pip install exited with code=${code} — see headroom/install.log`);
+ err.code = "INSTALL_FAILED";
+ reject(err);
+ }
+ });
+ });
+}
+
+// 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/src/lib/mcp/stdioSseBridge.js b/src/lib/mcp/stdioSseBridge.js
index c0e07b33..1bdc4268 100644
--- a/src/lib/mcp/stdioSseBridge.js
+++ b/src/lib/mcp/stdioSseBridge.js
@@ -153,6 +153,20 @@ function unregisterSession(name, sid) {
const entry = getStore().get(name);
if (!entry) return;
entry.sessions.delete(sid);
+ // No sessions left → kill child to avoid idle orphan process leak.
+ if (entry.sessions.size === 0) {
+ try { entry.proc.kill(); } catch { /* ignore */ }
+ getStore().delete(name);
+ }
+}
+
+// Kill all spawned MCP children — called on app shutdown to prevent orphans.
+function killAllBridges() {
+ const store = getStore();
+ for (const [name, entry] of store) {
+ try { entry.proc.kill(); } catch { /* ignore */ }
+ store.delete(name);
+ }
}
function sendToChild(name, jsonRpc) {
@@ -166,4 +180,4 @@ function isRunning(name) {
return !!(entry?.proc && !entry.proc.killed && entry.proc.exitCode === null);
}
-module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin };
+module.exports = { getOrSpawn, registerSession, unregisterSession, sendToChild, isRunning, findPlugin, killAllBridges };
diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js
index 71c5e008..9ecd2535 100644
--- a/src/lib/network/connectionProxy.js
+++ b/src/lib/network/connectionProxy.js
@@ -6,6 +6,33 @@ function normalizeString(value) {
return String(value).trim();
}
+// ─── Proxy pool rotation state (in-memory) ─────────────────────────
+const rotateState = new Map(); // providerId → { index }
+
+/**
+ * Pick one proxy pool ID from a list based on strategy.
+ * round-robin: cycle sequentially (in-memory, resets on restart)
+ * random: uniform random pick
+ * none/single: return first entry
+ */
+export function pickProxyPoolId(poolIds, strategy, providerId) {
+ if (!poolIds || poolIds.length === 0) return null;
+ if (poolIds.length === 1) return poolIds[0];
+
+ if (strategy === "round-robin") {
+ const state = rotateState.get(providerId) || { index: -1 };
+ state.index = (state.index + 1) % poolIds.length;
+ rotateState.set(providerId, state);
+ return poolIds[state.index];
+ }
+
+ if (strategy === "random") {
+ return poolIds[Math.floor(Math.random() * poolIds.length)];
+ }
+
+ return poolIds[0]; // "none" or unknown
+}
+
/**
* Normalize legacy proxy configuration.
*/
diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js
index 2f4715cf..1188b1fb 100644
--- a/src/lib/oauth/constants/oauth.js
+++ b/src/lib/oauth/constants/oauth.js
@@ -114,6 +114,10 @@ export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
// Kimchi OAuth Configuration (Browser token callback flow)
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
+// Grok CLI / Grok Build OAuth Configuration (Device Code Flow)
+// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
+export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
+
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -137,4 +141,5 @@ export const PROVIDERS = {
GITLAB: "gitlab",
CODEBUDDY: "codebuddy-cn",
KIMCHI: "kimchi",
+ GROK_CLI: "grok-cli",
};
diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js
index 361f384d..484ecf99 100644
--- a/src/lib/oauth/providers.js
+++ b/src/lib/oauth/providers.js
@@ -27,6 +27,7 @@ import {
GITLAB_CONFIG,
CODEBUDDY_CONFIG,
KIMCHI_CONFIG,
+ GROK_CLI_CONFIG,
getOAuthClientMetadata,
} from "./constants/oauth";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
@@ -255,6 +256,122 @@ const PROVIDERS = {
},
},
+ // Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com
+ "grok-cli": {
+ config: GROK_CLI_CONFIG,
+ flowType: "device_code",
+ requestDeviceCode: async (config) => {
+ const body = new URLSearchParams({
+ client_id: config.clientId,
+ scope: config.scope,
+ });
+ // Official CLI sends referrer=grok-build
+ if (config.referrer) body.set("referrer", config.referrer);
+
+ const response = await fetch(config.deviceCodeUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
+ },
+ body,
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Grok CLI device code request failed: ${error}`);
+ }
+
+ return await response.json();
+ },
+ pollToken: async (config, deviceCode) => {
+ const response = await fetch(config.tokenUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Accept: "application/json",
+ "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
+ },
+ body: new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ device_code: deviceCode,
+ client_id: config.clientId,
+ }),
+ });
+
+ let data;
+ try {
+ data = await response.json();
+ } catch {
+ const text = await response.text();
+ data = { error: "invalid_response", error_description: text };
+ }
+
+ // Device flow: 400 + authorization_pending is expected while user authorizes
+ const pending =
+ data?.error === "authorization_pending" ||
+ data?.error === "slow_down";
+ return {
+ ok: response.ok || pending,
+ data,
+ };
+ },
+ postExchange: async (tokens) => {
+ // Best-effort user profile from cli-chat-proxy (non-fatal)
+ try {
+ const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", {
+ headers: {
+ Authorization: `Bearer ${tokens.access_token}`,
+ Accept: "application/json",
+ "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
+ "x-xai-token-auth": "xai-grok-cli",
+ "x-grok-client-version": "0.2.93",
+ },
+ });
+ if (res.ok) return { user: await res.json() };
+ } catch {
+ /* ignore */
+ }
+ return { user: null };
+ },
+ mapTokens: (tokens, extra) => {
+ const email =
+ decodeXaiIdTokenEmail(tokens.id_token) ||
+ extractEmailFromAccessToken(tokens.access_token) ||
+ extra?.user?.email ||
+ null;
+ const userId =
+ extra?.user?.userId ||
+ extra?.user?.principalId ||
+ null;
+ const displayName = [extra?.user?.firstName, extra?.user?.lastName]
+ .filter(Boolean)
+ .join(" ")
+ .trim() || null;
+
+ return {
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token || null,
+ expiresIn: tokens.expires_in,
+ scope: tokens.scope,
+ // Top-level for dashboard connection cards
+ email: email || undefined,
+ displayName: displayName || undefined,
+ // Mirror identity into providerSpecificData so GrokCliExecutor can set
+ // x-email / x-userid without depending on top-level credential shape.
+ providerSpecificData: {
+ authMethod: "device_code",
+ idToken: tokens.id_token || null,
+ email: email || null,
+ userId,
+ hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null,
+ subscriptionTier: extra?.user?.subscriptionTier ?? null,
+ },
+ };
+ },
+ },
+
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
@@ -777,6 +894,9 @@ const PROVIDERS = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
+ name: extra?.userInfo?.login || extra?.userInfo?.name,
+ displayName: extra?.userInfo?.name || extra?.userInfo?.login,
+ email: extra?.userInfo?.email || null,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
diff --git a/src/lib/pxpipe/events.js b/src/lib/pxpipe/events.js
new file mode 100644
index 00000000..b1bb880d
--- /dev/null
+++ b/src/lib/pxpipe/events.js
@@ -0,0 +1,125 @@
+import fs from "fs";
+import path from "path";
+import { PXPIPE_DIR } from "./install.js";
+
+const EVENTS_FILE = path.join(PXPIPE_DIR, "events.jsonl");
+const ROTATED_FILE = path.join(PXPIPE_DIR, "events.jsonl.1");
+const MAX_FILE_BYTES = 5 * 1024 * 1024;
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function ensureDir() {
+ if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
+}
+
+// Fire-and-forget: stats must never break the request path.
+export function appendPxpipeEvent(event) {
+ try {
+ ensureDir();
+ try {
+ const stat = fs.statSync(EVENTS_FILE);
+ if (stat.size > MAX_FILE_BYTES) fs.renameSync(EVENTS_FILE, ROTATED_FILE);
+ } catch { /* no file yet */ }
+ fs.appendFile(EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...event }) + "\n", () => {});
+ } catch { /* ignore */ }
+}
+
+export function readPxpipeEvents({ sinceMs = null, limit = null } = {}) {
+ const events = [];
+ for (const file of [ROTATED_FILE, EVENTS_FILE]) {
+ try {
+ if (!fs.existsSync(file)) continue;
+ for (const line of fs.readFileSync(file, "utf8").split("\n")) {
+ if (!line) continue;
+ try {
+ const ev = JSON.parse(line);
+ if (sinceMs && ev.ts < sinceMs) continue;
+ events.push(ev);
+ } catch { /* skip corrupt line */ }
+ }
+ } catch { /* ignore */ }
+ }
+ events.sort((a, b) => a.ts - b.ts);
+ return limit ? events.slice(-limit) : events;
+}
+
+function emptyTotals() {
+ return {
+ requests: 0, compressed: 0, bypassed: 0, errors: 0,
+ tokensBeforeEst: 0, tokensAfterEst: 0, tokensSavedEst: 0, savedPct: 0,
+ imagesGenerated: 0, compressionTimeMs: 0, avgCompressionMs: 0,
+ };
+}
+
+function accumulate(totals, ev) {
+ totals.requests++;
+ if (ev.applied) {
+ totals.compressed++;
+ totals.tokensBeforeEst += ev.tokensBeforeEst || 0;
+ totals.tokensAfterEst += ev.tokensAfterEst || 0;
+ totals.tokensSavedEst += ev.tokensSavedEst || 0;
+ totals.imagesGenerated += ev.imageCount || 0;
+ totals.compressionTimeMs += ev.durationMs || 0;
+ } else if (ev.reason === "transform_error" || ev.reason === "timeout") {
+ totals.errors++;
+ } else {
+ totals.bypassed++;
+ }
+}
+
+function finalize(totals) {
+ totals.savedPct = totals.tokensBeforeEst > 0
+ ? +((totals.tokensSavedEst / totals.tokensBeforeEst) * 100).toFixed(2)
+ : 0;
+ totals.avgCompressionMs = totals.compressed > 0
+ ? Math.round(totals.compressionTimeMs / totals.compressed)
+ : 0;
+ return totals;
+}
+
+// Aggregated stats for the dashboard: all-time + windowed totals, a daily
+// tokens-saved timeline (last `timelineDays`), and the most recent events.
+export function getPxpipeStats({ timelineDays = 30, recentLimit = 100 } = {}) {
+ const events = readPxpipeEvents();
+ const now = Date.now();
+ const startOfToday = new Date(new Date(now).setHours(0, 0, 0, 0)).getTime();
+
+ const windows = {
+ all: emptyTotals(),
+ today: emptyTotals(),
+ yesterday: emptyTotals(),
+ last7d: emptyTotals(),
+ last30d: emptyTotals(),
+ };
+
+ const timeline = new Map();
+ for (let i = timelineDays - 1; i >= 0; i--) {
+ const day = new Date(startOfToday - i * DAY_MS);
+ timeline.set(day.toISOString().slice(0, 10), { date: day.toISOString().slice(0, 10), tokensSavedEst: 0, compressed: 0, requests: 0 });
+ }
+
+ for (const ev of events) {
+ accumulate(windows.all, ev);
+ if (ev.ts >= startOfToday) accumulate(windows.today, ev);
+ else if (ev.ts >= startOfToday - DAY_MS) accumulate(windows.yesterday, ev);
+ if (ev.ts >= now - 7 * DAY_MS) accumulate(windows.last7d, ev);
+ if (ev.ts >= now - 30 * DAY_MS) accumulate(windows.last30d, ev);
+
+ const key = new Date(ev.ts).toISOString().slice(0, 10);
+ const bucket = timeline.get(key);
+ if (bucket) {
+ bucket.requests++;
+ if (ev.applied) {
+ bucket.compressed++;
+ bucket.tokensSavedEst += ev.tokensSavedEst || 0;
+ }
+ }
+ }
+
+ for (const w of Object.values(windows)) finalize(w);
+
+ return {
+ windows,
+ timeline: [...timeline.values()],
+ recent: events.slice(-recentLimit).reverse(),
+ };
+}
diff --git a/src/lib/pxpipe/install.js b/src/lib/pxpipe/install.js
new file mode 100644
index 00000000..cdf5a8ce
--- /dev/null
+++ b/src/lib/pxpipe/install.js
@@ -0,0 +1,123 @@
+import fs from "fs";
+import path from "path";
+import { spawn, execSync } from "child_process";
+import { DATA_DIR } from "@/lib/dataDir.js";
+
+export const PXPIPE_DIR = path.join(DATA_DIR, "pxpipe");
+export const PXPIPE_PACKAGE = "pxpipe-proxy";
+const INSTALL_LOG = path.join(PXPIPE_DIR, "install.log");
+const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
+
+const IS_WIN = process.platform === "win32";
+const NPM_CMD = IS_WIN ? "npm.cmd" : "npm";
+
+// Same PATH extension trick as headroom/detect.js: packaged/launchd environments
+// often miss the Node bin dirs.
+const EXTRA_BINS = IS_WIN
+ ? [`${process.env.ProgramFiles || ""}\\nodejs`, `${process.env.APPDATA || ""}\\npm`]
+ : ["/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME || ""}/.local/bin`, "/usr/bin", "/bin"];
+const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter);
+
+let installInFlight = null;
+
+function ensureDir() {
+ if (!fs.existsSync(PXPIPE_DIR)) fs.mkdirSync(PXPIPE_DIR, { recursive: true });
+}
+
+export function packageRoot() {
+ return path.join(PXPIPE_DIR, "node_modules", PXPIPE_PACKAGE);
+}
+
+export function libraryEntry() {
+ return path.join(packageRoot(), "dist", "core", "library.js");
+}
+
+export function findNpm() {
+ try {
+ const out = execSync(`${IS_WIN ? "where" : "which"} npm`, {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ }).toString().trim();
+ return out ? out.split(/\r?\n/)[0].trim() : null;
+ } catch {
+ return null;
+ }
+}
+
+// { installed, version, path } — installed means the library entry exists on disk.
+export function getInstallInfo() {
+ try {
+ const pkgJson = path.join(packageRoot(), "package.json");
+ if (!fs.existsSync(pkgJson) || !fs.existsSync(libraryEntry())) {
+ return { installed: false, version: null, path: null };
+ }
+ const pkg = JSON.parse(fs.readFileSync(pkgJson, "utf8"));
+ return { installed: true, version: pkg.version || null, path: packageRoot() };
+ } catch {
+ return { installed: false, version: null, path: null };
+ }
+}
+
+export function isInstalling() {
+ return installInFlight !== null;
+}
+
+// Install (or repair by reinstalling) pxpipe-proxy into DATA_DIR/pxpipe.
+// Serialized: concurrent calls await the same run.
+export function installPxpipe() {
+ if (installInFlight) return installInFlight;
+ installInFlight = runInstall().finally(() => { installInFlight = null; });
+ return installInFlight;
+}
+
+async function runInstall() {
+ const npm = findNpm();
+ if (!npm) {
+ const err = new Error("npm not found on PATH — Node.js/npm is required to install PXPIPE");
+ err.code = "NPM_NOT_FOUND";
+ throw err;
+ }
+
+ ensureDir();
+ const pkgJson = path.join(PXPIPE_DIR, "package.json");
+ if (!fs.existsSync(pkgJson)) {
+ fs.writeFileSync(pkgJson, JSON.stringify({ name: "9router-pxpipe-host", private: true }, null, 2));
+ }
+
+ const outFd = fs.openSync(INSTALL_LOG, "a");
+ fs.writeSync(outFd, `\n[${new Date().toISOString()}] npm install ${PXPIPE_PACKAGE}@latest\n`);
+
+ await new Promise((resolve, reject) => {
+ const child = spawn(npm, ["install", `${PXPIPE_PACKAGE}@latest`, "--no-audit", "--no-fund", "--omit=dev"], {
+ cwd: PXPIPE_DIR,
+ stdio: ["ignore", outFd, outFd],
+ windowsHide: true,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ });
+ const timer = setTimeout(() => {
+ child.kill("SIGKILL");
+ reject(new Error("npm install timed out after 5 minutes — see install.log"));
+ }, INSTALL_TIMEOUT_MS);
+ child.once("error", (e) => { clearTimeout(timer); reject(e); });
+ child.once("exit", (code) => {
+ clearTimeout(timer);
+ if (code === 0) resolve();
+ else reject(new Error(`npm install exited with code ${code} — see install.log`));
+ });
+ }).finally(() => fs.closeSync(outFd));
+
+ const info = getInstallInfo();
+ if (!info.installed) throw new Error("install finished but package is missing — see install.log");
+ return info;
+}
+
+export function getInstallLogTail(maxLines = 200) {
+ try {
+ if (!fs.existsSync(INSTALL_LOG)) return "";
+ const lines = fs.readFileSync(INSTALL_LOG, "utf8").split(/\r?\n/).filter(Boolean);
+ return lines.slice(-maxLines).join("\n");
+ } catch {
+ return "";
+ }
+}
diff --git a/src/lib/pxpipe/loader.js b/src/lib/pxpipe/loader.js
new file mode 100644
index 00000000..fbd418f5
--- /dev/null
+++ b/src/lib/pxpipe/loader.js
@@ -0,0 +1,70 @@
+import { pathToFileURL } from "url";
+import { getInstallInfo, libraryEntry } from "./install.js";
+
+// Module cache: pxpipe is loaded once per process ("started") and dropped on
+// "stop". In library mode start/stop govern the in-process module, not a daemon.
+let cached = null; // { module, version, loadedAt }
+let loadPromise = null;
+
+export function getLoadedInfo() {
+ return cached ? { loaded: true, version: cached.version, loadedAt: cached.loadedAt } : { loaded: false };
+}
+
+export async function loadPxpipe() {
+ if (cached) return cached;
+ if (loadPromise) return loadPromise;
+ loadPromise = doLoad().finally(() => { loadPromise = null; });
+ return loadPromise;
+}
+
+async function doLoad() {
+ const info = getInstallInfo();
+ if (!info.installed) {
+ const err = new Error("PXPIPE is not installed");
+ err.code = "NOT_INSTALLED";
+ throw err;
+ }
+ // Cache-bust per version so Repair/upgrade takes effect without a server restart.
+ const url = `${pathToFileURL(libraryEntry()).href}?v=${encodeURIComponent(info.version || "0")}`;
+ const mod = await import(/* webpackIgnore: true */ url);
+ if (typeof mod.transformAnthropicMessages !== "function") {
+ throw new Error("installed pxpipe package does not export transformAnthropicMessages");
+ }
+ cached = { module: mod, version: info.version, loadedAt: Date.now() };
+ return cached;
+}
+
+export function unloadPxpipe() {
+ const wasLoaded = !!cached;
+ cached = null;
+ return wasLoaded;
+}
+
+// Transform function for the request pipeline; null when unavailable (fail-open).
+// autoLoad controls whether a cold cache triggers a load (first request warms it).
+export async function getTransform({ autoLoad = true } = {}) {
+ try {
+ if (!cached && !autoLoad) return null;
+ const { module: mod } = await loadPxpipe();
+ return mod.transformAnthropicMessages;
+ } catch {
+ return null;
+ }
+}
+
+// Health self-test: run a tiny synthetic Claude request through the transformer.
+// A healthy module parses it and answers with a machine-readable reason.
+export async function selfTest() {
+ const startedAt = Date.now();
+ const { module: mod } = await loadPxpipe();
+ const body = new TextEncoder().encode(JSON.stringify({
+ model: "claude-fable-5",
+ max_tokens: 16,
+ messages: [{ role: "user", content: "ping" }],
+ }));
+ const result = await mod.transformAnthropicMessages({ body, model: "claude-fable-5" });
+ if (!result || typeof result.applied !== "boolean" || !(result.body instanceof Uint8Array)) {
+ throw new Error("transform returned an unexpected shape");
+ }
+ return { ok: true, reason: result.reason, durationMs: Date.now() - startedAt };
+}
diff --git a/src/lib/pxpipe/service.js b/src/lib/pxpipe/service.js
new file mode 100644
index 00000000..d117c82b
--- /dev/null
+++ b/src/lib/pxpipe/service.js
@@ -0,0 +1,49 @@
+import { getInstallInfo, isInstalling, findNpm } from "./install.js";
+import { getLoadedInfo, loadPxpipe, selfTest } from "./loader.js";
+
+// Aggregate status for the Token Saver card and /api/pxpipe/status.
+// "running" in library mode = module loaded into this process.
+export function getPxpipeStatus() {
+ const install = getInstallInfo();
+ const loaded = getLoadedInfo();
+ return {
+ installed: install.installed,
+ installing: isInstalling(),
+ version: install.version,
+ path: install.path,
+ running: loaded.loaded,
+ loadedAt: loaded.loadedAt || null,
+ uptimeMs: loaded.loaded ? Date.now() - loaded.loadedAt : 0,
+ npmAvailable: !!findNpm(),
+ mode: "library", // in-process transform, not an external proxy
+ };
+}
+
+// PRD health checklist, adapted to library mode: installed? → module loads
+// (the "executable found / port listening" equivalent) → test request transforms.
+export async function runHealthCheck() {
+ const checks = [];
+ const fail = (error) => ({ healthy: false, checks, error });
+
+ const install = getInstallInfo();
+ checks.push({ id: "installed", label: "PXPIPE installed", ok: install.installed, detail: install.version ? `v${install.version}` : null });
+ if (!install.installed) return fail("pxpipe not installed");
+
+ try {
+ await loadPxpipe();
+ checks.push({ id: "module", label: "Transform module loads", ok: true, detail: `v${install.version}` });
+ } catch (e) {
+ checks.push({ id: "module", label: "Transform module loads", ok: false, detail: e.message });
+ return fail(`Cannot load module: ${e.message}`);
+ }
+
+ try {
+ const test = await selfTest();
+ checks.push({ id: "transform", label: "Test request transforms", ok: true, detail: `${test.durationMs}ms (${test.reason})` });
+ } catch (e) {
+ checks.push({ id: "transform", label: "Test request transforms", ok: false, detail: e.message });
+ return fail(`Self-test failed: ${e.message}`);
+ }
+
+ return { healthy: true, checks, error: null };
+}
diff --git a/src/lib/requestDetailsDb.js b/src/lib/requestDetailsDb.js
index 26fa7b0b..0daaa8a4 100644
--- a/src/lib/requestDetailsDb.js
+++ b/src/lib/requestDetailsDb.js
@@ -1,4 +1,4 @@
// Shim → re-export from new SQLite-based DB layer (src/lib/db/)
export {
- saveRequestDetail, getRequestDetails, getRequestDetailById,
+ saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "@/lib/db/index.js";
diff --git a/src/mitm/manager.js b/src/mitm/manager.js
index b70fbdf0..a9d6e6a6 100644
--- a/src/mitm/manager.js
+++ b/src/mitm/manager.js
@@ -496,9 +496,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) {
fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
} catch (e) {
if (e.code === "EEXIST") {
- throw new Error("MITM server is already starting (lock contention)");
- }
- throw e;
+ let stale = false;
+ try {
+ const pid = parseInt(fs.readFileSync(LOCK_FILE, "utf-8").trim(), 10);
+ stale = !pid || !isProcessAlive(pid);
+ } catch { stale = true; } // unreadable lock → treat as stale
+ if (!stale) throw new Error("MITM server is already starting (lock contention)");
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ }
+ fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" });
+ } else throw e;
}
try {
diff --git a/src/shared/components/LanguageSwitcher.js b/src/shared/components/LanguageSwitcher.js
index b14a8179..7697e649 100644
--- a/src/shared/components/LanguageSwitcher.js
+++ b/src/shared/components/LanguageSwitcher.js
@@ -49,7 +49,8 @@ const getLocaleInfo = (locale) => {
"hu": { name: "Magyar", flag: "🇭🇺" },
"fi": { name: "Suomi", flag: "🇫🇮" },
"da": { name: "Dansk", flag: "🇩🇰" },
- "no": { name: "Norsk", flag: "🇳🇴" }
+ "no": { name: "Norsk", flag: "🇳🇴" },
+ "fa": { name: "فارسی", flag: "🇮🇷" }
};
return locales[locale] || { name: locale, flag: "🌐" };
};
diff --git a/src/shared/components/NoAuthProxyCard.js b/src/shared/components/NoAuthProxyCard.js
index df9db696..6229e60c 100644
--- a/src/shared/components/NoAuthProxyCard.js
+++ b/src/shared/components/NoAuthProxyCard.js
@@ -1,16 +1,22 @@
"use client";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import PropTypes from "prop-types";
import Card from "./Card";
import Select from "./Select";
import Badge from "./Badge";
const NONE_PROXY_POOL_VALUE = "__none__";
+const STRATEGIES = [
+ { value: "none", label: "None (single pool)" },
+ { value: "round-robin", label: "Round-robin" },
+ { value: "random", label: "Random" },
+];
export default function NoAuthProxyCard({ providerId }) {
const [proxyPools, setProxyPools] = useState([]);
const [proxyPoolId, setProxyPoolId] = useState(NONE_PROXY_POOL_VALUE);
+ const [rotateStrategy, setRotateStrategy] = useState("none");
const [saving, setSaving] = useState(false);
const [savedFlash, setSavedFlash] = useState(false);
@@ -24,20 +30,22 @@ export default function NoAuthProxyCard({ providerId }) {
setProxyPools(poolData.proxyPools || []);
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProxyPoolId(override.proxyPoolId || NONE_PROXY_POOL_VALUE);
+ setRotateStrategy(override.rotateStrategy || "none");
}).catch(() => {});
return () => { cancelled = true; };
}, [providerId]);
- const handleChange = async (newValue) => {
- setProxyPoolId(newValue);
+ const save = useCallback(async (poolId, strategy) => {
setSaving(true);
try {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
const current = data.providerStrategies || {};
const override = { ...(current[providerId] || {}) };
- if (newValue === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
- else override.proxyPoolId = newValue;
+ if (poolId === NONE_PROXY_POOL_VALUE) delete override.proxyPoolId;
+ else override.proxyPoolId = poolId;
+ if (strategy === "none") delete override.rotateStrategy;
+ else override.rotateStrategy = strategy;
const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerId];
else updated[providerId] = override;
@@ -49,12 +57,25 @@ export default function NoAuthProxyCard({ providerId }) {
setSavedFlash(true);
setTimeout(() => setSavedFlash(false), 1500);
} catch (e) {
- console.log("Save proxyPoolId error:", e);
+ console.log("Save proxy config error:", e);
} finally {
setSaving(false);
}
+ }, [providerId]);
+
+ const handlePoolChange = (newPoolId) => {
+ setProxyPoolId(newPoolId);
+ save(newPoolId, rotateStrategy);
};
+ const handleStrategyChange = (newStrategy) => {
+ setRotateStrategy(newStrategy);
+ save(proxyPoolId, newStrategy);
+ };
+
+ const canRotate = proxyPools.length >= 2;
+ const isRotation = rotateStrategy !== "none";
+
return (
@@ -67,16 +88,43 @@ export default function NoAuthProxyCard({ providerId }) {
{savedFlash && Saved}