@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.
Files changed (77) hide show
  1. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-B4OfGAwz.js +361 -0
  4. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DAlLdW8I.css +1 -0
  6. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js → mobile-BRhoDOUz.js} +1 -2
  7. package/bizar-dash/dist/assets/{mobile--0FBIKX3.js.map → mobile-BRhoDOUz.js.map} +1 -1
  8. package/bizar-dash/dist/assets/{mobile-OgRp8VIb.js → mobile-lbH6szyX.js} +2 -3
  9. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +1 -0
  10. package/bizar-dash/dist/index.html +3 -3
  11. package/bizar-dash/dist/mobile.html +2 -2
  12. package/bizar-dash/skills/bizar/SKILL.md +20 -0
  13. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +11 -0
  15. package/bizar-dash/skills/obsidian/SKILL.md +13 -0
  16. package/bizar-dash/src/server/api.mjs +6 -0
  17. package/bizar-dash/src/server/headroom.mjs +647 -0
  18. package/bizar-dash/src/server/memory-lightrag.mjs +281 -6
  19. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  20. package/bizar-dash/src/server/memory-store.mjs +151 -0
  21. package/bizar-dash/src/server/mods-loader.mjs +13 -5
  22. package/bizar-dash/src/server/providers-store.mjs +73 -1
  23. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  24. package/bizar-dash/src/server/routes/chat.mjs +38 -0
  25. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  26. package/bizar-dash/src/server/routes/lightrag.mjs +6 -2
  27. package/bizar-dash/src/server/routes/memory.mjs +439 -4
  28. package/bizar-dash/src/server/schedules-runner.mjs +4 -4
  29. package/bizar-dash/src/server/server.mjs +55 -37
  30. package/bizar-dash/src/server/watcher.mjs +2 -2
  31. package/bizar-dash/src/web/App.tsx +42 -6
  32. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  33. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  34. package/bizar-dash/src/web/components/Toast.tsx +1 -1
  35. package/bizar-dash/src/web/components/Topbar.tsx +3 -1
  36. package/bizar-dash/src/web/lib/api.ts +12 -11
  37. package/bizar-dash/src/web/lib/types.ts +16 -0
  38. package/bizar-dash/src/web/main.tsx +1 -0
  39. package/bizar-dash/src/web/styles/chat.css +2 -0
  40. package/bizar-dash/src/web/styles/main.css +52 -23
  41. package/bizar-dash/src/web/styles/memory.css +955 -0
  42. package/bizar-dash/src/web/styles/tasks.css +2 -0
  43. package/bizar-dash/src/web/views/Memory.tsx +141 -0
  44. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +3 -2
  45. package/bizar-dash/src/web/views/Overview.tsx +7 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +32 -1
  47. package/bizar-dash/src/web/views/Skills.tsx +3 -2
  48. package/bizar-dash/src/web/views/Tasks.tsx +4 -3
  49. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  50. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  51. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  52. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +356 -0
  53. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  54. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  55. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  56. package/bizar-dash/tests/cli-bugfixes.test.mjs +151 -0
  57. package/bizar-dash/tests/frontend-bugfixes.test.mjs +151 -0
  58. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  59. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  60. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  61. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  62. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  63. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  64. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  65. package/bizar-dash/tests/server-bugfixes.test.mjs +318 -0
  66. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  67. package/cli/artifact.mjs +11 -4
  68. package/cli/bin.mjs +211 -12
  69. package/cli/doctor.mjs +5 -3
  70. package/cli/memory.mjs +65 -21
  71. package/cli/provision.mjs +8 -2
  72. package/install.sh +1 -2
  73. package/package.json +3 -3
  74. package/bizar-dash/dist/assets/main-CDFKHzBg.css +0 -1
  75. package/bizar-dash/dist/assets/main-NYFpS2wY.js +0 -312
  76. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +0 -1
  77. 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
+ }