Merge remote-tracking branch 'upstream/master'

# Conflicts:
#	.gitignore
#	open-sse/handlers/chatCore.js
This commit is contained in:
decolua
2026-07-16 11:59:46 +07:00
162 changed files with 9368 additions and 1287 deletions
+41 -1
View File
@@ -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 })
+1 -1
View File
@@ -64,7 +64,7 @@ export {
// Request details
export {
saveRequestDetail, getRequestDetails, getRequestDetailById,
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "./repos/requestDetailsRepo.js";
// Export/import full DB
+33 -22
View File
@@ -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);
}
+24 -2
View File
@@ -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) {
+7
View File
@@ -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]);
+4
View File
@@ -43,6 +43,10 @@ const DEFAULT_SETTINGS = {
cavemanLevel: "full",
ponytailEnabled: false,
ponytailLevel: "full",
pxpipeEnabled: false,
pxpipeAutoInstall: true,
pxpipeMinChars: 25000,
pxpipeTimeoutMs: 15000,
};
async function readRaw() {
+1 -2
View File
@@ -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");
}
+5 -1
View File
@@ -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 = `
+95 -5
View File
@@ -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 } };
}
}
+135 -3
View File
@@ -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 ""; }
}
+15 -1
View File
@@ -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 };
+27
View File
@@ -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.
*/
+5
View File
@@ -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",
};
+120
View File
@@ -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,
+125
View File
@@ -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(),
};
}
+123
View File
@@ -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 "";
}
}
+70
View File
@@ -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 };
}
+49
View File
@@ -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 };
}
+1 -1
View File
@@ -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";