@polderlabs/bizar 4.4.13 → 4.5.1

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