@polderlabs/bizar 4.5.0 → 4.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
- package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
- package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
- package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/bizar/SKILL.md +20 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +11 -0
- package/bizar-dash/skills/obsidian/SKILL.md +13 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/headroom.mjs +647 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +151 -0
- package/bizar-dash/src/server/mods-loader.mjs +13 -5
- package/bizar-dash/src/server/providers-store.mjs +73 -1
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- package/bizar-dash/src/server/routes/chat.mjs +38 -0
- package/bizar-dash/src/server/routes/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
- package/bizar-dash/src/server/routes/memory.mjs +439 -4
- package/bizar-dash/src/server/schedules-runner.mjs +4 -4
- package/bizar-dash/src/server/server.mjs +55 -37
- package/bizar-dash/src/server/watcher.mjs +2 -2
- package/bizar-dash/src/web/App.tsx +42 -6
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/Toast.tsx +1 -1
- package/bizar-dash/src/web/components/Topbar.tsx +3 -1
- package/bizar-dash/src/web/lib/api.ts +12 -11
- package/bizar-dash/src/web/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/styles/chat.css +2 -0
- package/bizar-dash/src/web/styles/main.css +52 -23
- package/bizar-dash/src/web/styles/memory.css +955 -0
- package/bizar-dash/src/web/styles/tasks.css +2 -0
- package/bizar-dash/src/web/views/Memory.tsx +141 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
- package/bizar-dash/src/web/views/Overview.tsx +7 -2
- package/bizar-dash/src/web/views/Settings.tsx +32 -1
- package/bizar-dash/src/web/views/Skills.tsx +3 -2
- package/bizar-dash/src/web/views/Tasks.tsx +4 -3
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
- package/bizar-dash/tests/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -0
- package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
- package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
- package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
- package/cli/artifact.mjs +11 -4
- package/cli/bin.mjs +211 -12
- package/cli/doctor.mjs +5 -3
- package/cli/memory.mjs +65 -21
- package/cli/provision.mjs +8 -2
- package/install.sh +1 -2
- package/package.json +3 -3
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +0 -1
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/headroom.mjs
|
|
3
|
+
*
|
|
4
|
+
* v1.0.0 — Headroom CLI integration for the Bizar dashboard.
|
|
5
|
+
*
|
|
6
|
+
* Headroom (headroom-ai) is a context compression layer that sits between
|
|
7
|
+
* opencode and LLM providers. It compresses tool outputs, logs, RAG chunks,
|
|
8
|
+
* and conversation history by 60–95% before they reach the model.
|
|
9
|
+
*
|
|
10
|
+
* This module wraps the `headroom` CLI and exposes:
|
|
11
|
+
* - getHeadroomStatus() — live status (installed, version, proxy, wrapped)
|
|
12
|
+
* - getHeadroomStats() — compression statistics
|
|
13
|
+
* - installHeadroom() — pip/npm install
|
|
14
|
+
* - wrapOpencode() — run `headroom wrap opencode`
|
|
15
|
+
* - unwrapOpencode() — run `headroom unwrap opencode`
|
|
16
|
+
* - startProxy() — spawn `headroom proxy` as detached child
|
|
17
|
+
* - stopProxy() — kill the proxy process
|
|
18
|
+
* - getOpencodeConfig() — read opencode.json headroom status
|
|
19
|
+
* - withHeadroomProxy(url) — prepend Headroom proxy URL if enabled
|
|
20
|
+
* - headroomStartupHook() — auto-install/start/wrap on dashboard boot
|
|
21
|
+
*/
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
24
|
+
import { join, dirname } from 'node:path';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { readSettings } from './routes/_shared.mjs';
|
|
28
|
+
|
|
29
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const HOME = homedir();
|
|
32
|
+
const BIZAR_CACHE = join(HOME, '.cache', 'bizar');
|
|
33
|
+
const HEADROOM_PORT_FILE = join(BIZAR_CACHE, 'headroom.port');
|
|
34
|
+
const OPENCODE_JSON = join(HOME, '.config', 'opencode', 'opencode.json');
|
|
35
|
+
const DEFAULT_HEADROOM_PORT = 8787;
|
|
36
|
+
const DEFAULT_HEADROOM_HOST = '127.0.0.1';
|
|
37
|
+
|
|
38
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run a command, capture stdout + stderr, resolve with exit code.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} cmd
|
|
44
|
+
* @param {string[]} args
|
|
45
|
+
* @param {{ timeout?: number, cwd?: string, env?: Record<string, string> }} [opts]
|
|
46
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
|
|
47
|
+
*/
|
|
48
|
+
function runCmd(cmd, args, opts = {}) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const { timeout = 30000, cwd = HOME, env = process.env } = opts;
|
|
51
|
+
const child = spawn(cmd, args, { cwd, env, shell: true });
|
|
52
|
+
let stdout = '';
|
|
53
|
+
let stderr = '';
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
child.kill('SIGTERM');
|
|
56
|
+
}, timeout);
|
|
57
|
+
child.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
58
|
+
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
|
59
|
+
child.on('close', (code) => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 });
|
|
62
|
+
});
|
|
63
|
+
child.on('error', (err) => {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
resolve({ stdout, stderr: err.message, exitCode: 1 });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Safe read of Headroom port from the cached file.
|
|
72
|
+
* @returns {number | null}
|
|
73
|
+
*/
|
|
74
|
+
function readHeadroomPort() {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(HEADROOM_PORT_FILE)) return null;
|
|
77
|
+
const port = parseInt(readFileSync(HEADROOM_PORT_FILE, 'utf8').trim(), 10);
|
|
78
|
+
return Number.isFinite(port) && port > 0 ? port : null;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if a TCP port is in use (quick probe).
|
|
86
|
+
* @param {number} port
|
|
87
|
+
* @param {string} [host]
|
|
88
|
+
* @returns {Promise<boolean>}
|
|
89
|
+
*/
|
|
90
|
+
async function isPortInUse(port, host = '127.0.0.1') {
|
|
91
|
+
const { connect } = await import('node:net');
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const s = connect({ port, host, timeout: 1000 }, () => {
|
|
94
|
+
s.destroy();
|
|
95
|
+
resolve(true);
|
|
96
|
+
});
|
|
97
|
+
s.on('error', () => resolve(false));
|
|
98
|
+
s.on('timeout', () => { s.destroy(); resolve(false); });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the proxy PID from the port file comment (if any), or try to find
|
|
104
|
+
* it via /proc. Returns null if not determinable.
|
|
105
|
+
* @param {number} port
|
|
106
|
+
* @returns {Promise<number | null>}
|
|
107
|
+
*/
|
|
108
|
+
async function findProxyPid(port) {
|
|
109
|
+
// Try reading from the pid file (same dir as port file)
|
|
110
|
+
const pidFile = join(BIZAR_CACHE, 'headroom.pid');
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(pidFile)) {
|
|
113
|
+
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
114
|
+
if (Number.isFinite(pid) && pid > 0) return pid;
|
|
115
|
+
}
|
|
116
|
+
} catch { /* ignore */ }
|
|
117
|
+
|
|
118
|
+
// Try to find process listening on the port via /proc/net/tcp (Linux only).
|
|
119
|
+
// On macOS/Windows /proc does not exist — return null early to avoid noisy errors.
|
|
120
|
+
if (process.platform !== 'linux') {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const netTcp = readFileSync('/proc/net/tcp', 'utf8');
|
|
125
|
+
const portHex = port.toString(16).toUpperCase().padStart(4, '0');
|
|
126
|
+
const lines = netTcp.split('\n');
|
|
127
|
+
for (const line of lines.slice(1)) {
|
|
128
|
+
// Format: sl local_address rem_address st tx_queue rx_queue ... inode
|
|
129
|
+
const parts = line.trim().split(/\s+/);
|
|
130
|
+
if (parts.length < 10) continue;
|
|
131
|
+
const local = parts[1];
|
|
132
|
+
const localPortHex = local.split(':')[1];
|
|
133
|
+
if (localPortHex?.toUpperCase() === portHex) {
|
|
134
|
+
// Try to get PID from the socket inode
|
|
135
|
+
const inode = parts[9];
|
|
136
|
+
if (inode && inode !== '0') {
|
|
137
|
+
// Scan /proc/*/fd/* for socket inodes
|
|
138
|
+
const { readdirSync, readlinkSync } = await import('node:fs');
|
|
139
|
+
const procDir = '/proc';
|
|
140
|
+
const pids = readdirSync(procDir).filter(
|
|
141
|
+
(n) => /^\d+$/.test(n) && !isNaN(parseInt(n, 10)),
|
|
142
|
+
);
|
|
143
|
+
for (const pid of pids) {
|
|
144
|
+
try {
|
|
145
|
+
const fdDir = join(procDir, pid, 'fd');
|
|
146
|
+
const fds = readdirSync(fdDir);
|
|
147
|
+
for (const fd of fds) {
|
|
148
|
+
try {
|
|
149
|
+
const link = readlinkSync(join(fdDir, fd));
|
|
150
|
+
if (link.includes(`socket:[${inode}]`)) {
|
|
151
|
+
return parseInt(pid, 10);
|
|
152
|
+
}
|
|
153
|
+
} catch { /* skip */ }
|
|
154
|
+
}
|
|
155
|
+
} catch { /* skip */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch { /* ignore */ }
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Status ──────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the live Headroom status.
|
|
169
|
+
*
|
|
170
|
+
* @returns {Promise<{
|
|
171
|
+
* installed: boolean,
|
|
172
|
+
* version: string | null,
|
|
173
|
+
* proxyRunning: boolean,
|
|
174
|
+
* proxyPort: number | null,
|
|
175
|
+
* proxyPid: number | null,
|
|
176
|
+
* wrapped: boolean,
|
|
177
|
+
* configPath: string | null,
|
|
178
|
+
* healthy: 'ok' | 'warn' | 'fail',
|
|
179
|
+
* messages: string[],
|
|
180
|
+
* }>}
|
|
181
|
+
*/
|
|
182
|
+
export async function getHeadroomStatus() {
|
|
183
|
+
const messages = [];
|
|
184
|
+
let healthy = 'ok';
|
|
185
|
+
|
|
186
|
+
// 1. Check if headroom binary is on PATH
|
|
187
|
+
const { stdout: versionOut, exitCode: versionCode } = await runCmd('headroom', ['--version']);
|
|
188
|
+
const installed = versionCode === 0 && versionOut.length > 0;
|
|
189
|
+
const version = installed ? versionOut.replace(/^headroom\s+/i, '').trim() : null;
|
|
190
|
+
|
|
191
|
+
if (!installed) {
|
|
192
|
+
messages.push('Headroom is not installed. Run `pip install "headroom-ai[all]"` or enable auto-install in Settings.');
|
|
193
|
+
healthy = 'warn';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. Check proxy port
|
|
197
|
+
const proxyPort = readHeadroomPort();
|
|
198
|
+
let proxyRunning = false;
|
|
199
|
+
let proxyPid = null;
|
|
200
|
+
|
|
201
|
+
if (proxyPort) {
|
|
202
|
+
proxyRunning = await isPortInUse(proxyPort);
|
|
203
|
+
if (proxyRunning) {
|
|
204
|
+
proxyPid = await findProxyPid(proxyPort);
|
|
205
|
+
messages.push(`Proxy running on ${proxyPort}${proxyPid ? ` (PID ${proxyPid})` : ''}.`);
|
|
206
|
+
} else {
|
|
207
|
+
messages.push(`Proxy port ${proxyPort} is not in use (stale port file).`);
|
|
208
|
+
healthy = healthy === 'ok' ? 'warn' : healthy;
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
messages.push('Proxy not started yet.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 3. Check if opencode is wrapped (opencode.json has headroom provider)
|
|
215
|
+
const { configPath, hasHeadroomProvider } = await getOpencodeConfig();
|
|
216
|
+
const wrapped = hasHeadroomProvider;
|
|
217
|
+
|
|
218
|
+
if (!wrapped && installed) {
|
|
219
|
+
messages.push('opencode is not wrapped with Headroom. Run `headroom wrap opencode` or use Settings.');
|
|
220
|
+
healthy = healthy === 'ok' ? 'warn' : healthy;
|
|
221
|
+
} else if (wrapped) {
|
|
222
|
+
messages.push('opencode is wrapped with Headroom.');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (healthy === 'ok') {
|
|
226
|
+
messages.push('Headroom is healthy.');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
installed,
|
|
231
|
+
version,
|
|
232
|
+
proxyRunning,
|
|
233
|
+
proxyPort,
|
|
234
|
+
proxyPid,
|
|
235
|
+
wrapped,
|
|
236
|
+
configPath,
|
|
237
|
+
healthy,
|
|
238
|
+
messages,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Stats ───────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Get Headroom compression statistics.
|
|
246
|
+
*
|
|
247
|
+
* @param {{ hours?: number }} [opts]
|
|
248
|
+
* @returns {Promise<{
|
|
249
|
+
* tokensSaved: number,
|
|
250
|
+
* compressionRatio: number,
|
|
251
|
+
* cacheHits: number,
|
|
252
|
+
* transforms: number,
|
|
253
|
+
* raw: object,
|
|
254
|
+
* } | { error: string }>}
|
|
255
|
+
*/
|
|
256
|
+
export async function getHeadroomStats({ hours = 24 } = {}) {
|
|
257
|
+
const { stdout, stderr, exitCode } = await runCmd(
|
|
258
|
+
'headroom',
|
|
259
|
+
['perf', '--hours', String(hours), '--format', 'json'],
|
|
260
|
+
{ timeout: 15000 },
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (exitCode !== 0) {
|
|
264
|
+
return { error: stderr || `headroom perf failed with exit code ${exitCode}` };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
const raw = JSON.parse(stdout);
|
|
269
|
+
return {
|
|
270
|
+
tokensSaved: raw.tokens_saved ?? raw.tokensSaved ?? 0,
|
|
271
|
+
compressionRatio: raw.compression_ratio ?? raw.compressionRatio ?? 0,
|
|
272
|
+
cacheHits: raw.cache_hits ?? raw.cacheHits ?? 0,
|
|
273
|
+
transforms: raw.transforms ?? 0,
|
|
274
|
+
raw,
|
|
275
|
+
};
|
|
276
|
+
} catch {
|
|
277
|
+
return { error: `Failed to parse headroom perf JSON: ${stdout.slice(0, 200)}` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Install ─────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Install Headroom via pip (preferred) or npm fallback.
|
|
285
|
+
*
|
|
286
|
+
* @param {{ force?: boolean }} [opts]
|
|
287
|
+
* @returns {Promise<{ ok: boolean, installed: boolean, version: string | null, method: string }>}
|
|
288
|
+
*/
|
|
289
|
+
export async function installHeadroom({ force = false } = {}) {
|
|
290
|
+
// Check if already installed
|
|
291
|
+
if (!force) {
|
|
292
|
+
const { exitCode } = await runCmd('headroom', ['--version'], { timeout: 5000 });
|
|
293
|
+
if (exitCode === 0) {
|
|
294
|
+
const { stdout } = await runCmd('headroom', ['--version'], { timeout: 5000 });
|
|
295
|
+
return { ok: true, installed: true, version: stdout.trim() || null, method: 'already-installed' };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Try pip first
|
|
300
|
+
try {
|
|
301
|
+
const { stdout: pipOut, exitCode: pipCode } = await runCmd(
|
|
302
|
+
'pip',
|
|
303
|
+
['install', '--user', 'headroom-ai[all]'],
|
|
304
|
+
{ timeout: 120000 },
|
|
305
|
+
);
|
|
306
|
+
if (pipCode === 0) {
|
|
307
|
+
const { stdout: verOut } = await runCmd('headroom', ['--version'], { timeout: 5000 });
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
installed: true,
|
|
311
|
+
version: verOut.trim() || null,
|
|
312
|
+
method: 'pip',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
} catch { /* fall through */ }
|
|
316
|
+
|
|
317
|
+
// Fall back to npm
|
|
318
|
+
try {
|
|
319
|
+
const { stdout: npmOut, exitCode: npmCode } = await runCmd(
|
|
320
|
+
'npm',
|
|
321
|
+
['install', '-g', 'headroom-ai'],
|
|
322
|
+
{ timeout: 120000 },
|
|
323
|
+
);
|
|
324
|
+
if (npmCode === 0) {
|
|
325
|
+
const { stdout: verOut } = await runCmd('headroom', ['--version'], { timeout: 5000 });
|
|
326
|
+
return {
|
|
327
|
+
ok: true,
|
|
328
|
+
installed: true,
|
|
329
|
+
version: verOut.trim() || null,
|
|
330
|
+
method: 'npm',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
} catch { /* fall through */ }
|
|
334
|
+
|
|
335
|
+
return { ok: false, installed: false, version: null, method: 'none' };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Wrap / Unwrap ───────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Run `headroom wrap opencode --port <port>` and persist the port.
|
|
342
|
+
*
|
|
343
|
+
* @param {{ port?: number }} [opts]
|
|
344
|
+
* @returns {Promise<{ ok: boolean, port: number, pid?: number, log: string }>}
|
|
345
|
+
*/
|
|
346
|
+
export async function wrapOpencode({ port = DEFAULT_HEADROOM_PORT } = {}) {
|
|
347
|
+
mkdirSync(BIZAR_CACHE, { recursive: true });
|
|
348
|
+
writeFileSync(HEADROOM_PORT_FILE, String(port), 'utf8');
|
|
349
|
+
|
|
350
|
+
const { stdout, stderr, exitCode } = await runCmd(
|
|
351
|
+
'headroom',
|
|
352
|
+
['wrap', 'opencode', '--port', String(port)],
|
|
353
|
+
{ timeout: 30000 },
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
const ok = exitCode === 0;
|
|
357
|
+
return {
|
|
358
|
+
ok,
|
|
359
|
+
port,
|
|
360
|
+
log: ok ? stdout : `${stdout}\n${stderr}`.trim(),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Run `headroom unwrap opencode`.
|
|
366
|
+
*
|
|
367
|
+
* @returns {Promise<{ ok: boolean, log: string }>}
|
|
368
|
+
*/
|
|
369
|
+
export async function unwrapOpencode() {
|
|
370
|
+
const { stdout, stderr, exitCode } = await runCmd(
|
|
371
|
+
'headroom',
|
|
372
|
+
['unwrap', 'opencode'],
|
|
373
|
+
{ timeout: 30000 },
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
return { ok: exitCode === 0, log: `${stdout}\n${stderr}`.trim() };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Proxy process management ────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/** @type {Map<number, { pid: number }>} */
|
|
382
|
+
const _proxyProcesses = new Map();
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Start the Headroom proxy as a detached child process.
|
|
386
|
+
*
|
|
387
|
+
* @param {{ port?: number, host?: string }} [opts]
|
|
388
|
+
* @returns {Promise<{ ok: boolean, pid: number | null, port: number, logPath: string }>}
|
|
389
|
+
*/
|
|
390
|
+
export async function startProxy({ port = DEFAULT_HEADROOM_PORT, host = DEFAULT_HEADROOM_HOST } = {}) {
|
|
391
|
+
mkdirSync(BIZAR_CACHE, { recursive: true });
|
|
392
|
+
writeFileSync(HEADROOM_PORT_FILE, String(port), 'utf8');
|
|
393
|
+
|
|
394
|
+
const logPath = join(BIZAR_CACHE, `headroom-proxy-${port}.log`);
|
|
395
|
+
|
|
396
|
+
return new Promise((resolve) => {
|
|
397
|
+
const child = spawn(
|
|
398
|
+
'headroom',
|
|
399
|
+
['proxy', '--port', String(port), '--host', host],
|
|
400
|
+
{
|
|
401
|
+
detached: true,
|
|
402
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
403
|
+
env: process.env,
|
|
404
|
+
cwd: HOME,
|
|
405
|
+
},
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
let logWritten = false;
|
|
409
|
+
function writeLog(text) {
|
|
410
|
+
if (!logWritten) {
|
|
411
|
+
try {
|
|
412
|
+
writeFileSync(logPath, text, 'utf8');
|
|
413
|
+
logWritten = true;
|
|
414
|
+
} catch { /* ignore */ }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
child.stdout?.on('data', (d) => writeLog(d.toString()));
|
|
419
|
+
child.stderr?.on('data', (d) => writeLog(d.toString()));
|
|
420
|
+
|
|
421
|
+
child.on('error', (err) => {
|
|
422
|
+
writeLog(`spawn error: ${err.message}`);
|
|
423
|
+
resolve({ ok: false, pid: null, port, logPath });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Give it a moment to fail
|
|
427
|
+
setTimeout(() => {
|
|
428
|
+
const pid = child.pid;
|
|
429
|
+
if (pid) {
|
|
430
|
+
_proxyProcesses.set(port, { pid });
|
|
431
|
+
// Write PID file
|
|
432
|
+
try {
|
|
433
|
+
writeFileSync(join(BIZAR_CACHE, 'headroom.pid'), String(pid), 'utf8');
|
|
434
|
+
} catch { /* ignore */ }
|
|
435
|
+
resolve({ ok: true, pid, port, logPath });
|
|
436
|
+
} else {
|
|
437
|
+
resolve({ ok: false, pid: null, port, logPath });
|
|
438
|
+
}
|
|
439
|
+
}, 2000);
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Stop the Headroom proxy process.
|
|
445
|
+
*
|
|
446
|
+
* @returns {Promise<{ ok: boolean, killed: number[] }>}
|
|
447
|
+
*/
|
|
448
|
+
export async function stopProxy() {
|
|
449
|
+
const port = readHeadroomPort() || DEFAULT_HEADROOM_PORT;
|
|
450
|
+
const killed = [];
|
|
451
|
+
|
|
452
|
+
// Try PID file first
|
|
453
|
+
const pidFile = join(BIZAR_CACHE, 'headroom.pid');
|
|
454
|
+
try {
|
|
455
|
+
if (existsSync(pidFile)) {
|
|
456
|
+
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
457
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
458
|
+
try {
|
|
459
|
+
process.kill(pid, 'SIGTERM');
|
|
460
|
+
killed.push(pid);
|
|
461
|
+
} catch { /* ignore */ }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} catch { /* ignore */ }
|
|
465
|
+
|
|
466
|
+
// Also kill by port lookup
|
|
467
|
+
const pid = await findProxyPid(port);
|
|
468
|
+
if (pid && !killed.includes(pid)) {
|
|
469
|
+
try {
|
|
470
|
+
process.kill(pid, 'SIGTERM');
|
|
471
|
+
killed.push(pid);
|
|
472
|
+
} catch { /* ignore */ }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Try SIGKILL if SIGTERM didn't work
|
|
476
|
+
for (const p of killed) {
|
|
477
|
+
try {
|
|
478
|
+
process.kill(p, 0); // check if still alive
|
|
479
|
+
} catch {
|
|
480
|
+
// process is gone
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Clean up PID file
|
|
485
|
+
try {
|
|
486
|
+
if (existsSync(pidFile)) {
|
|
487
|
+
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
488
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ }
|
|
489
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ }
|
|
490
|
+
}
|
|
491
|
+
} catch { /* ignore */ }
|
|
492
|
+
|
|
493
|
+
try { require('node:fs').unlinkSync(pidFile); } catch { /* ignore */ }
|
|
494
|
+
_proxyProcesses.delete(port);
|
|
495
|
+
|
|
496
|
+
return { ok: killed.length > 0, killed };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Opencode config ─────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Read opencode.json and check for Headroom provider entry.
|
|
503
|
+
*
|
|
504
|
+
* @returns {Promise<{ configPath: string, hasHeadroomProvider: boolean, baseURL: string | null }>}
|
|
505
|
+
*/
|
|
506
|
+
export async function getOpencodeConfig() {
|
|
507
|
+
const configPath = OPENCODE_JSON;
|
|
508
|
+
if (!existsSync(configPath)) {
|
|
509
|
+
return { configPath, hasHeadroomProvider: false, baseURL: null };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
514
|
+
const providers = raw?.provider || raw?.providers || {};
|
|
515
|
+
const headroomProvider = providers?.headroom;
|
|
516
|
+
const hasHeadroomProvider = Boolean(headroomProvider);
|
|
517
|
+
const baseURL = headroomProvider?.baseURL || headroomProvider?.options?.baseURL || null;
|
|
518
|
+
|
|
519
|
+
return { configPath, hasHeadroomProvider, baseURL };
|
|
520
|
+
} catch {
|
|
521
|
+
return { configPath, hasHeadroomProvider: false, baseURL: null };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ── Headroom proxy URL helper ───────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* If Headroom is enabled and the proxy is running, return the proxy URL
|
|
529
|
+
* prepended to the given original URL. Otherwise returns the URL unchanged.
|
|
530
|
+
*
|
|
531
|
+
* Usage:
|
|
532
|
+
* const url = withHeadroomProxy('https://api.anthropic.com/v1/messages');
|
|
533
|
+
* // if headroom proxy is on 8787 → 'http://127.0.0.1:8787/v1/https://api.anthropic.com/v1/messages'
|
|
534
|
+
*
|
|
535
|
+
* This is a URL transformation: headroom proxy expects the target URL as
|
|
536
|
+
* the path. We just return the transformed URL string; callers apply it.
|
|
537
|
+
*
|
|
538
|
+
* @param {string} url
|
|
539
|
+
* @param {{ port?: number }} [opts]
|
|
540
|
+
* @returns {string}
|
|
541
|
+
*/
|
|
542
|
+
export function withHeadroomProxy(url, { port = DEFAULT_HEADROOM_PORT } = {}) {
|
|
543
|
+
// eslint-disable-next-line no-sync
|
|
544
|
+
const settings = (() => {
|
|
545
|
+
try {
|
|
546
|
+
const s = readSettings();
|
|
547
|
+
return s?.data?.headroom;
|
|
548
|
+
} catch {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
})();
|
|
552
|
+
|
|
553
|
+
if (!settings?.enabled) return url;
|
|
554
|
+
if (!url) return url;
|
|
555
|
+
|
|
556
|
+
// Only proxy OpenAI-compatible URLs (most providers use this)
|
|
557
|
+
try {
|
|
558
|
+
const parsed = new URL(url);
|
|
559
|
+
// Don't re-proxy already-proxied URLs
|
|
560
|
+
if (parsed.host === '127.0.0.1' || parsed.host === 'localhost') return url;
|
|
561
|
+
const proxyBase = `http://${DEFAULT_HEADROOM_HOST}:${port}`;
|
|
562
|
+
return `${proxyBase}/v1/${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
|
|
563
|
+
} catch {
|
|
564
|
+
return url;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ── Startup hook ───────────────────────────────────────────────────────────
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Headroom startup hook — called by server.mjs on dashboard boot.
|
|
572
|
+
*
|
|
573
|
+
* If `headroom.autoInstall` is true and Headroom is missing: install.
|
|
574
|
+
* If `headroom.autoStart` is true and proxy isn't running: start proxy.
|
|
575
|
+
* If `headroom.autoWrap` is true and opencode isn't wrapped: wrap.
|
|
576
|
+
* If `headroom.routeAllProviders` is true: configure providers to use proxy.
|
|
577
|
+
*
|
|
578
|
+
* All errors are caught and logged — startup must not fail if Headroom
|
|
579
|
+
* has issues.
|
|
580
|
+
*
|
|
581
|
+
* @param {import('../web/lib/types').HeadroomSettings} headroomSettings
|
|
582
|
+
* @returns {Promise<{ installed: boolean, wrapped: boolean, proxied: boolean }>}
|
|
583
|
+
*/
|
|
584
|
+
export async function headroomStartupHook(headroomSettings) {
|
|
585
|
+
const result = { installed: false, wrapped: false, proxied: false };
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const status = await getHeadroomStatus();
|
|
589
|
+
|
|
590
|
+
// 1. Auto-install
|
|
591
|
+
if (headroomSettings.autoInstall && !status.installed) {
|
|
592
|
+
try {
|
|
593
|
+
const inst = await installHeadroom();
|
|
594
|
+
result.installed = inst.installed;
|
|
595
|
+
if (inst.installed) {
|
|
596
|
+
console.log('[headroom] Installed via', inst.method, inst.version || '');
|
|
597
|
+
}
|
|
598
|
+
} catch (err) {
|
|
599
|
+
console.warn('[headroom] Auto-install failed:', err?.message || err);
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
result.installed = status.installed;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Re-check status after potential install
|
|
606
|
+
const statusAfter = await getHeadroomStatus();
|
|
607
|
+
|
|
608
|
+
// 2. Auto-start proxy
|
|
609
|
+
if (headroomSettings.autoStart && !statusAfter.proxyRunning) {
|
|
610
|
+
try {
|
|
611
|
+
const port = headroomSettings.port || DEFAULT_HEADROOM_PORT;
|
|
612
|
+
const host = headroomSettings.host || DEFAULT_HEADROOM_HOST;
|
|
613
|
+
await startProxy({ port, host });
|
|
614
|
+
console.log(`[headroom] Proxy started on ${host}:${port}`);
|
|
615
|
+
} catch (err) {
|
|
616
|
+
console.warn('[headroom] Auto-start proxy failed:', err?.message || err);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 3. Auto-wrap opencode
|
|
621
|
+
if (headroomSettings.autoWrap && !statusAfter.wrapped) {
|
|
622
|
+
try {
|
|
623
|
+
const port = headroomSettings.port || DEFAULT_HEADROOM_PORT;
|
|
624
|
+
const wrapped = await wrapOpencode({ port });
|
|
625
|
+
result.wrapped = wrapped.ok;
|
|
626
|
+
if (wrapped.ok) {
|
|
627
|
+
console.log('[headroom] opencode wrapped on port', port);
|
|
628
|
+
}
|
|
629
|
+
} catch (err) {
|
|
630
|
+
console.warn('[headroom] Auto-wrap failed:', err?.message || err);
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
result.wrapped = statusAfter.wrapped;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// 4. Route all providers (handled at the provider level — mark flag)
|
|
637
|
+
result.proxied = headroomSettings.routeAllProviders;
|
|
638
|
+
if (headroomSettings.routeAllProviders) {
|
|
639
|
+
console.log('[headroom] routeAllProviders enabled — configure provider baseURLs to point at the proxy');
|
|
640
|
+
}
|
|
641
|
+
} catch (err) {
|
|
642
|
+
// Never let headroom failures crash the dashboard startup
|
|
643
|
+
console.warn('[headroom] Startup hook error:', err?.message || err);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return result;
|
|
647
|
+
}
|