From 64a133530cc1ca6a2875eec2451588157ba8c5c0 Mon Sep 17 00:00:00 2001 From: Akhil Singh Date: Sun, 21 Jun 2026 21:01:01 -0700 Subject: [PATCH] Add headroom snapshot polling and quota endpoints --- HEADROOM.md | 16 +- README.md | 2 +- apps/server/src/app.ts | 3 + apps/server/src/db.ts | 16 ++ apps/server/src/headroom-snapshot-poller.ts | 59 +++++++ apps/server/src/routes/dashboard.ts | 66 ++++++++ apps/web/src/Dashboard.tsx | 173 +++++++++++++++++++- apps/web/src/api.ts | 52 ++++++ scripts/dev | 99 ++++++----- 9 files changed, 421 insertions(+), 65 deletions(-) create mode 100644 apps/server/src/headroom-snapshot-poller.ts diff --git a/HEADROOM.md b/HEADROOM.md index 4cc68a1..6a7b0c8 100644 --- a/HEADROOM.md +++ b/HEADROOM.md @@ -2,7 +2,7 @@ Headroom is a local context-compression proxy that sits between AI Watchtower and your LiteLLM endpoint. It intercepts each chat request, runs a compression pass -over the message history using an on-device ONNX model (kompress-base), and +over the message history using an on-device ONNX model (kompress-v2-base), and forwards the compressed prompt to LiteLLM. Only the compressed text leaves the machine. The full, uncompressed conversation is never sent anywhere. @@ -37,7 +37,7 @@ find each piece of the implementation in the source tree. 1. You send a message in the Headroom tab. 2. The AI Watchtower server appends it to the conversation transcript and POSTs the full history to http://localhost:8787/v1/chat/completions (the proxy). -3. The proxy loads the kompress-base ONNX model (already on disk after first run) +3. The proxy loads the kompress-v2-base ONNX model (already on disk after first run) and runs a semantic compression pass over the message history. 4. Compression only fires when the context window exceeds 500 tokens. Shorter conversations pass through unchanged. @@ -63,7 +63,7 @@ AI Watchtower server (apps/server/src/routes/litellm.ts) v Headroom proxy (http://localhost:8787) | - | semantic compression (kompress-base, ONNX, local CPU/GPU) + | semantic compression (kompress-v2-base, ONNX, local CPU/GPU) | POST /v1/chat/completions v LiteLLM (LITELLM_BASE_URL) @@ -92,8 +92,8 @@ The script (scripts/dev) does the following in order: 3. Runs pnpm install if node_modules is absent. 4. Checks whether headroom is on PATH. - If not, prompts to install it: pip install headroom-ai httpx[http2] -5. Checks whether kompress-base is cached at - ~/.cache/huggingface/hub/models--chopratejas--kompress-base +5. Checks whether kompress-v2-base is cached at + ~/.cache/huggingface/hub/models--chopratejas--kompress-v2-base - If not cached, starts the proxy online so it can download the model from HuggingFace (one-time, ~several hundred MB). - If already cached, sets HF_HUB_OFFLINE=1 before starting the proxy. @@ -338,8 +338,8 @@ to the headroom-ai documentation (https://github.com/chopratejas/headroom). All compression runs entirely on your machine: -- The kompress-base model is a local ONNX file cached at - ~/.cache/huggingface/hub/models--chopratejas--kompress-base. +- The kompress-v2-base model is a local ONNX file cached at + ~/.cache/huggingface/hub/models--chopratejas--kompress-v2-base. - After the first run (model download), HF_HUB_OFFLINE=1 is set so no further HuggingFace network calls are made. - HEADROOM_TELEMETRY=off is set unconditionally by scripts/dev; the proxy sends @@ -380,7 +380,7 @@ What stays local: | Dev runner (install + launch) | scripts/dev | | Proxy log file | data/headroom.log (runtime, gitignored) | | Persistent savings | ~/.headroom/proxy_savings.json (outside repo) | -| Model cache | ~/.cache/huggingface/hub/models--chopratejas--kompress-base | +| Model cache | ~/.cache/huggingface/hub/models--chopratejas--kompress-v2-base | ## Known limitations and gotchas diff --git a/README.md b/README.md index 8eb2248..8154e53 100644 --- a/README.md +++ b/README.md @@ -468,7 +468,7 @@ Headroom is a transparent context compression proxy that reduces tokens sent to **Setup:** `pnpm dev:one` handles everything automatically: - Installs `headroom-ai` via pip if not present (prompts once) -- Downloads the `kompress-base` compression model from HuggingFace (one-time, ~first run) +- Downloads the `kompress-v2-base` compression model from HuggingFace (one-time, ~first run) - Starts the proxy on `http://localhost:8787` before the app - Routes all Headroom tab calls through the proxy → your `LITELLM_BASE_URL` diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4151a34..8845fdc 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -9,6 +9,7 @@ import { dashboardRouter } from "./routes/dashboard"; import { analyticsRouter } from "./routes/analytics"; import { aiCoachAnalyticsRouter } from "./routes/aiCoachAnalytics"; import { getValidModelIds } from "./litellm"; +import { startHeadroomSnapshotPoller } from "./headroom-snapshot-poller"; export type AgentsFleetServer = { app: Express; @@ -42,5 +43,7 @@ export function createApp(): AgentsFleetServer { app.use("/api", analyticsRouter()); app.use("/api", aiCoachAnalyticsRouter()); + startHeadroomSnapshotPoller(); + return { app, hub, processManager }; } diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 68c7927..e84e6dd 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -111,6 +111,22 @@ export function getDb(): Db { CREATE INDEX IF NOT EXISTS idx_session_analytics_harness ON session_analytics(harness); + + -- Periodic poll of the headroom proxy's live-only metrics + -- (/subscription-window, /quota, /stats). Headroom keeps these in + -- memory and in its own JSON files under ~/.headroom, which can be + -- lost (restarts, history caps); this table is our durable copy so the + -- dashboard can show trends even after headroom forgets. + CREATE TABLE IF NOT EXISTS headroom_snapshots ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + subscription_window TEXT NULL, + quota TEXT NULL, + stats TEXT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_headroom_snapshots_created_at + ON headroom_snapshots(created_at); `); // Migrations (sessions columns) diff --git a/apps/server/src/headroom-snapshot-poller.ts b/apps/server/src/headroom-snapshot-poller.ts new file mode 100644 index 0000000..a7193b2 --- /dev/null +++ b/apps/server/src/headroom-snapshot-poller.ts @@ -0,0 +1,59 @@ +import crypto from "node:crypto"; +import { getDb } from "./db"; + +const HEADROOM_PROXY_BASE = process.env.HEADROOM_PROXY_URL ?? "http://localhost:8787"; +const POLL_INTERVAL_MS = 5 * 60 * 1000; +const MAX_ROWS = 5000; +const FETCH_TIMEOUT_MS = 3000; + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +async function pollOnce(): Promise { + const base = HEADROOM_PROXY_BASE.replace(/\/$/, ""); + const [subscriptionWindow, quota, stats] = await Promise.all([ + fetchJson(`${base}/subscription-window`), + fetchJson(`${base}/quota`), + fetchJson(`${base}/stats?cached=1`), + ]); + + // Proxy isn't running / unreachable -- nothing to persist this tick. + if (subscriptionWindow === null && quota === null && stats === null) return; + + const db = getDb(); + db.prepare( + `INSERT INTO headroom_snapshots (id, created_at, subscription_window, quota, stats) + VALUES (?, ?, ?, ?, ?)`, + ).run( + crypto.randomUUID(), + new Date().toISOString(), + subscriptionWindow ? JSON.stringify(subscriptionWindow) : null, + quota ? JSON.stringify(quota) : null, + stats ? JSON.stringify(stats) : null, + ); + + // Best-effort cap so this table can't grow unbounded on a long-running server. + db.prepare( + `DELETE FROM headroom_snapshots WHERE id NOT IN ( + SELECT id FROM headroom_snapshots ORDER BY created_at DESC LIMIT ? + )`, + ).run(MAX_ROWS); +} + +let pollerStarted = false; + +export function startHeadroomSnapshotPoller(): void { + if (pollerStarted) return; + pollerStarted = true; + pollOnce().catch(() => {}); + setInterval(() => { + pollOnce().catch(() => {}); + }, POLL_INTERVAL_MS); +} diff --git a/apps/server/src/routes/dashboard.ts b/apps/server/src/routes/dashboard.ts index ee86c7b..d351353 100644 --- a/apps/server/src/routes/dashboard.ts +++ b/apps/server/src/routes/dashboard.ts @@ -444,5 +444,71 @@ export function dashboardRouter(): Router { } }); + /** + * GET /api/dashboard/headroom/quota?url= + * Proxies to {url}/subscription-window and {url}/quota on the local headroom + * proxy. Returns { configured: false } if no url provided. + */ + router.get("/dashboard/headroom/quota", async (req: Request, res: Response) => { + const url = typeof req.query.url === "string" ? req.query.url.trim() : ""; + if (!url) return res.json({ configured: false }); + + try { + new URL(url); + } catch { + return res.status(400).json({ error: { message: "Invalid headroom proxy URL" } }); + } + + try { + const base = url.replace(/\/$/, ""); + const [subRes, quotaRes] = await Promise.all([ + fetch(`${base}/subscription-window`, { signal: AbortSignal.timeout(3000) }), + fetch(`${base}/quota`, { signal: AbortSignal.timeout(3000) }), + ]); + const subscriptionWindow = subRes.ok ? await subRes.json() as Record : null; + const quota = quotaRes.ok ? await quotaRes.json() as Record : null; + return res.json({ configured: true, subscriptionWindow, quota }); + } catch (e) { + return res.status(502).json({ error: { message: `Could not reach headroom proxy: ${String(e)}` } }); + } + }); + + /** + * GET /api/dashboard/headroom/snapshots?limit=200 + * Returns our own durable poll history of headroom's live-only metrics + * (subscription window, quota, stats), so trends survive even if + * headroom's own in-memory/JSON state resets or caps its history. + */ + router.get("/dashboard/headroom/snapshots", (req: Request, res: Response) => { + const limit = Math.min(2000, Math.max(1, Number(req.query.limit) || 200)); + const db = getDb(); + const rows = db + .prepare( + `SELECT id, created_at, subscription_window, quota, stats + FROM headroom_snapshots + ORDER BY created_at DESC + LIMIT ?`, + ) + .all(limit) as Array<{ + id: string; + created_at: string; + subscription_window: string | null; + quota: string | null; + stats: string | null; + }>; + + const snapshots = rows + .map((r) => ({ + id: r.id, + createdAt: r.created_at, + subscriptionWindow: r.subscription_window ? JSON.parse(r.subscription_window) as unknown : null, + quota: r.quota ? JSON.parse(r.quota) as unknown : null, + stats: r.stats ? JSON.parse(r.stats) as unknown : null, + })) + .reverse(); + + return res.json({ snapshots }); + }); + return router; } diff --git a/apps/web/src/Dashboard.tsx b/apps/web/src/Dashboard.tsx index 6fe0119..949c5b5 100644 --- a/apps/web/src/Dashboard.tsx +++ b/apps/web/src/Dashboard.tsx @@ -14,9 +14,14 @@ import { getDashboardByModel, getDashboardByRepo, getDashboardStats, + getHeadroomQuota, getHeadroomRequests, + getHeadroomSnapshots, getLiteLLMSpend, + type HeadroomQuotaResponse, + type HeadroomRateLimitWindow, type HeadroomRequestRow, + type HeadroomSnapshot, type LiteLLMDailyActivity, type LiteLLMSpendLog, } from "./api"; @@ -1338,14 +1343,175 @@ function HeadroomRequestsTable() { ); } +const HEADROOM_PROXY_BASE = "http://localhost:8787"; + +function HeadroomWindowBar({ label, win }: { label: string; win?: HeadroomRateLimitWindow }) { + if (!win) return null; + const pct = Math.min(100, Math.max(0, win.utilization_pct)); + const color = pct >= 90 ? "#dc2626" : pct >= 70 ? "#d97706" : "#16a34a"; + const resetsAt = win.resets_at ? new Date(win.resets_at).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "—"; + return ( + + + {label} + + {win.used.toLocaleString()} / {win.limit.toLocaleString()} ({pct.toFixed(1)}%) + + + + + + + Resets {resetsAt}{win.resets_at_estimated ? " (estimated)" : ""} + + + ); +} + +function HeadroomSnapshotHistory() { + const [snapshots, setSnapshots] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + getHeadroomSnapshots(100) + .then((s) => { if (alive) setSnapshots(s); }) + .catch((e) => { if (alive) setError(String(e)); }); + return () => { alive = false; }; + }, []); + + if (error) return null; + const rows = snapshots.filter((s) => s.subscriptionWindow?.latest?.five_hour || s.quota); + if (rows.length === 0) { + return ( + + No persisted history yet — snapshots are recorded every 5 minutes while the server runs, + even if headroom's own state resets. + + ); + } + + return ( + + + Persisted history ({rows.length} snapshots, polled every 5 min — survives headroom restarts) + + + + + + {["Time", "5h util%", "7d util%"].map((h) => ( + + ))} + + + + {rows.slice().reverse().map((s) => { + const fiveHour = s.subscriptionWindow?.latest?.five_hour; + const sevenDay = s.subscriptionWindow?.latest?.seven_day; + return ( + + + + + + ); + })} + +
{h}
+ {new Date(s.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + {fiveHour ? `${fiveHour.utilization_pct.toFixed(1)}%` : "—"}{sevenDay ? `${sevenDay.utilization_pct.toFixed(1)}%` : "—"}
+
+
+ ); +} + +function HeadroomQuotaPanel() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + const poll = () => { + getHeadroomQuota(HEADROOM_PROXY_BASE) + .then((d) => { if (alive) { setData(d); setError(null); } }) + .catch((e) => { if (alive) setError(String(e)); }); + }; + poll(); + const id = setInterval(poll, 10000); + return () => { alive = false; clearInterval(id); }; + }, []); + + if (error) return {error}; + if (!data) return Loading…; + if (!data.configured) { + return Headroom proxy not configured.; + } + + const latest = data.subscriptionWindow?.latest; + const quota = data.quota; + const hasSubscription = !!latest && (latest.five_hour || latest.seven_day || latest.seven_day_opus); + // The subscription tracker also registers itself as a quota provider under + // this key, which would just re-dump the same raw state already shown above + // in "Claude Subscription Window" -- exclude it to avoid the duplicate/confusing entry. + const quotaEntries = quota + ? Object.entries(quota).filter(([key, v]) => v !== null && key !== "subscription_window") + : []; + + return ( + + Claude Subscription Window + {hasSubscription ? ( + + + + + + ) : ( + + Subscription tracking is not enabled (not signed in via Claude subscription OAuth, or no usage observed yet). + + )} + + + + + Provider Quota + {quotaEntries.length === 0 ? ( + No quota data reported by any provider yet. + ) : ( + + {quotaEntries.map(([key, stats]) => ( + + {key} + + + {Object.entries(stats ?? {}).map(([k, v]) => ( + + {k} + + {typeof v === "object" ? JSON.stringify(v) : String(v)} + + + ))} + + + + ))} + + )} + + ); +} + function HeadroomSection() { - const [tab, setTab] = useState<"dashboard" | "requests">("dashboard"); + const [tab, setTab] = useState<"dashboard" | "requests" | "quota">("dashboard"); return ( {/* Subtab bar */} - {(["dashboard", "requests"] as const).map((key) => ( + {(["dashboard", "requests", "quota"] as const).map((key) => ( setTab(key)} @@ -1357,7 +1523,7 @@ function HeadroomSection() { "&:hover": { bgcolor: tab === key ? "primary.50" : "action.hover" }, }} > - {key === "dashboard" ? "Headroom Dashboard" : "Request Log"} + {key === "dashboard" ? "Headroom Dashboard" : key === "requests" ? "Request Log" : "Subscription & Quota"} ))} @@ -1370,6 +1536,7 @@ function HeadroomSection() { /> )} {tab === "requests" && } + {tab === "quota" && } ); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 198cd2d..922690b 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -639,6 +639,58 @@ export type HeadroomStatsResponse = }; }; +export type HeadroomRateLimitWindow = { + used: number; + limit: number; + utilization_pct: number; + resets_at: string | null; + synthesized?: boolean; + resets_at_estimated?: boolean; + render_warning?: string; +}; + +export type HeadroomQuotaResponse = + | { configured: false } + | { + configured: true; + subscriptionWindow: { + latest?: { + five_hour?: HeadroomRateLimitWindow; + seven_day?: HeadroomRateLimitWindow; + seven_day_opus?: HeadroomRateLimitWindow; + }; + } | null; + quota: Record | null> | null; + }; + +export async function getHeadroomQuota(proxyUrl: string): Promise { + const res = await fetch(`/api/dashboard/headroom/quota?url=${encodeURIComponent(proxyUrl)}`); + const json = await parseJson(res); + if (isApiError(json)) throw new Error(json.error.message); + return json as HeadroomQuotaResponse; +} + +export type HeadroomSnapshot = { + id: string; + createdAt: string; + subscriptionWindow: { + latest?: { + five_hour?: HeadroomRateLimitWindow; + seven_day?: HeadroomRateLimitWindow; + seven_day_opus?: HeadroomRateLimitWindow; + }; + } | null; + quota: Record | null> | null; + stats: Record | null; +}; + +export async function getHeadroomSnapshots(limit = 200): Promise { + const res = await fetch(`/api/dashboard/headroom/snapshots?limit=${limit}`); + const json = await parseJson<{ snapshots: HeadroomSnapshot[] } | ApiErrorShape>(res); + if (isApiError(json)) throw new Error(json.error.message); + return (json as { snapshots: HeadroomSnapshot[] }).snapshots; +} + export async function getHeadroomStats(proxyUrl: string): Promise { const res = await fetch(`/api/dashboard/headroom/stats?url=${encodeURIComponent(proxyUrl)}`); const json = await parseJson(res); diff --git a/scripts/dev b/scripts/dev index b7e39fe..dfed285 100644 --- a/scripts/dev +++ b/scripts/dev @@ -62,17 +62,29 @@ HEADROOM_PID="" # HuggingFace caches models at ~/.cache/huggingface/hub/models---- HF_CACHE_DIR="${HF_HOME:-${HOME}/.cache/huggingface}/hub" -KOMPRESS_CACHE="$HF_CACHE_DIR/models--chopratejas--kompress-base" +KOMPRESS_CACHE="$HF_CACHE_DIR/models--chopratejas--kompress-v2-base" ensure_kompress_model() { if [ -d "$KOMPRESS_CACHE" ]; then - echo "[agents_fleet] kompress-base model already cached -- enabling HF_HUB_OFFLINE" + echo "[agents_fleet] kompress-v2-base model already cached -- enabling HF_HUB_OFFLINE" + export HF_HUB_OFFLINE=1 + return + fi + + echo "[agents_fleet] kompress-v2-base model not found in cache -- downloading from HuggingFace (one-time, ~260MB)..." + # The proxy itself only loads Kompress lazily on the first compressible + # request (it never eager-loads at startup), so without this step the + # model would silently never download until real traffic happened to + # trigger it. Fetch it directly via headroom's own loader so it's ready + # before the proxy starts. + if "$REPO_ROOT/.venv/bin/python" -c " +from headroom.transforms.kompress_compressor import _load_kompress +_load_kompress(allow_download=True) +" >/dev/null 2>&1; then + echo "[agents_fleet] kompress-v2-base model downloaded and cached." export HF_HUB_OFFLINE=1 else - echo "[agents_fleet] kompress-base model not found in cache -- downloading from HuggingFace (one-time)..." - # headroom downloads the model on first proxy start; just let it run online this once. - # HF_HUB_OFFLINE stays unset so the download succeeds. - echo "[agents_fleet] Future runs will use the local cache (no HuggingFace calls)." + echo "[agents_fleet] WARNING: could not pre-download kompress-v2-base model; it will be fetched lazily on first compressible request instead." fi } @@ -86,7 +98,9 @@ launch_headroom() { sleep 1 fi - # Ensure torch is installed (needed for ML compression). Uses pip cache after first download. + mkdir -p "$REPO_ROOT/data/headroom" + + # Install torch if missing (uses pip cache after first run — fast no-op on repeat) local VENV_PIP_CMD="$REPO_ROOT/.venv/bin/pip" if [ -f "$VENV_PIP_CMD" ] && ! "$REPO_ROOT/.venv/bin/python" -c "import torch" >/dev/null 2>&1; then echo "[agents_fleet] Installing torch for ML compression (cached after first run)..." @@ -104,8 +118,9 @@ launch_headroom() { TOKENIZERS_PARALLELISM=false \ OPENAI_TARGET_API_URL="${LITELLM_BASE_URL:-}" \ OPENAI_API_KEY="${LITELLM_API_KEY:-}" \ - "$HEADROOM_CMD" proxy --port "$HEADROOM_PORT" \ + "${HEADROOM_CMD:-headroom}" proxy --port "$HEADROOM_PORT" --no-telemetry \ --memory \ + --memory-db-path "$REPO_ROOT/data/headroom/memory.db" \ --log-file "$REPO_ROOT/data/headroom-requests.jsonl" \ >"$REPO_ROOT/data/headroom.log" 2>&1 & HEADROOM_PID=$! @@ -128,19 +143,19 @@ launch_headroom() { } start_headroom() { - # Check for headroom in venv or system PATH + # Prefer .venv for isolation, but fall back to system headroom + export HEADROOM_CMD="" + + # If .venv exists with headroom, use it if [ -f "$REPO_ROOT/.venv/bin/headroom" ]; then export HEADROOM_CMD="$REPO_ROOT/.venv/bin/headroom" - elif command -v headroom >/dev/null 2>&1; then - export HEADROOM_CMD="headroom" - else - export HEADROOM_CMD="" - fi - - if [ -n "$HEADROOM_CMD" ]; then ensure_kompress_model launch_headroom - else + return + fi + + # If .venv doesn't exist but we want to set it up, prompt for install + if [ ! -d "$REPO_ROOT/.venv" ] || [ ! -f "$REPO_ROOT/.venv/bin/pip" ]; then printf "%s" "[agents_fleet] headroom not found. Install it now? [Y/n]: " if [ -r /dev/tty ]; then read -r answer "$INSTALL_LOG" 2>&1; then + if "$VENV_PIP" install -r "$REPO_ROOT/requirements.txt" >/dev/null 2>&1; then echo "[agents_fleet] ✓ headroom dependencies installed successfully" # Update HEADROOM_CMD to use venv version HEADROOM_CMD="$REPO_ROOT/.venv/bin/headroom" ensure_kompress_model launch_headroom else - echo "" echo "[agents_fleet] ERROR: pip install failed" - echo "[agents_fleet] Error output:" - cat "$INSTALL_LOG" | sed 's/^/ /' - echo "" - printf "%s" "[agents_fleet] Retry? [y/N]: " - if [ -r /dev/tty ]; then - read -r retry_answer /dev/null 2>&1; then + export HEADROOM_CMD="headroom" + ensure_kompress_model + launch_headroom else - read -r retry_answer || retry_answer="n" + echo "[agents_fleet] Skipping headroom setup" fi - - case "$retry_answer" in - [yY]*) - if "$VENV_PIP" install --upgrade pip >/dev/null 2>&1; then - if "$VENV_PIP" install "headroom-ai" "httpx[http2]" >"$INSTALL_LOG" 2>&1; then - echo "[agents_fleet] ✓ headroom-ai installed successfully" - HEADROOM_CMD="$REPO_ROOT/.venv/bin/headroom" - ensure_kompress_model - launch_headroom - else - echo "[agents_fleet] Installation failed again. Skipping headroom setup" - fi - fi - ;; - *) - echo "" - echo "[agents_fleet] To install manually:" - echo " source .venv/bin/activate" - echo " pip install headroom-ai httpx[http2]" - echo "" - echo "[agents_fleet] Skipping headroom setup" - ;; - esac fi - - rm -f "$INSTALL_LOG" ;; esac + else + # .venv exists but no headroom installed in it - try system headroom + if command -v headroom >/dev/null 2>&1; then + export HEADROOM_CMD="headroom" + ensure_kompress_model + launch_headroom + fi fi }