vibeostheog 0.19.1 → 0.19.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.
@@ -0,0 +1,340 @@
1
+ import type { TuiPlugin } from "@opencode-ai/plugin/tui"
2
+ import { createSignal } from "solid-js"
3
+ import { existsSync, readFileSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { homedir } from "node:os"
6
+
7
+ const DEFAULT_PORT = 9578
8
+ const TIERS_FILE = join(homedir(), ".claude/model-tiers.json")
9
+
10
+ function getBaseUrl() {
11
+ try {
12
+ if (existsSync(TIERS_FILE)) {
13
+ const tiers = JSON.parse(readFileSync(TIERS_FILE, "utf-8"))
14
+ const port = Number(tiers?.selection?.mcp_port)
15
+ if (Number.isFinite(port) && port > 0) return `http://localhost:${port}`
16
+ }
17
+ } catch {}
18
+ return `http://localhost:${DEFAULT_PORT}`
19
+ }
20
+
21
+ type StatusResponse = {
22
+ todos: {
23
+ total: number
24
+ pending: number
25
+ }
26
+
27
+ enabled: boolean
28
+ active_slot: string
29
+ enforce: boolean
30
+ flow_enforcer: boolean
31
+ flow_extract_todos: boolean
32
+ tdd_enforcer: boolean
33
+ tdd_strict: boolean
34
+ thinking: string
35
+ current_model: string
36
+ credit_percent: number
37
+ version: string
38
+ backend_connected?: boolean
39
+ backend_health_url?: string | null
40
+ model_locked?: boolean
41
+ locked_slot?: string | null
42
+ locked_model?: string | null
43
+ }
44
+
45
+ type SavingsResponse = {
46
+ lifetime: {
47
+ delegation_usd: number
48
+ cache_usd: number
49
+ missed_context7_usd: number
50
+ total_warns: number
51
+ }
52
+ current_session: {
53
+ delegation_usd: number
54
+ cache_usd: number
55
+ warns_count: number
56
+ tool_breakdown: Record<string, number>
57
+ }
58
+ cache_hits_this_session: number
59
+ trend: string
60
+ savings_rate_per_hour: number
61
+ }
62
+
63
+ // Named export for TUI auto-discovery
64
+ export const vibeOSTui = async (api, _options, _meta) => {
65
+ try {
66
+ if (api?.ui?.toast) {
67
+ api.ui.toast({ variant: "info", message: "vibeOS TUI plugin executing" })
68
+ }
69
+ if (typeof process !== "undefined") {
70
+ process.stderr?.write?.("[vibeOS-tui] plugin function called\n")
71
+ }
72
+ } catch (e) {
73
+ if (typeof process !== "undefined") {
74
+ process.stderr?.write?.("[vibeOS-tui] ERROR: " + String(e) + "\n")
75
+ }
76
+ }
77
+ }
78
+
79
+ const plugin: TuiPlugin = async (api, _options, _meta) => {
80
+ const [status, setStatus] = createSignal<StatusResponse | null>(null)
81
+ const [savings, setSavings] = createSignal<SavingsResponse | null>(null)
82
+ const [error, setError] = createSignal<string | null>(null)
83
+
84
+ const poll = async () => {
85
+ try {
86
+ const baseUrl = getBaseUrl()
87
+ const [s, sa] = await Promise.all([
88
+ fetch(`${baseUrl}/status`).then((r) => r.json()),
89
+ fetch(`${baseUrl}/savings`).then((r) => r.json()),
90
+ ])
91
+ setStatus(s)
92
+ setSavings(sa)
93
+ setError(null)
94
+ } catch {
95
+ setError("vibeOS MCP offline")
96
+ }
97
+ }
98
+
99
+ await poll()
100
+ const timer = setInterval(poll, 3000)
101
+
102
+ api.lifecycle.onDispose(() => {
103
+ clearInterval(timer)
104
+ })
105
+
106
+ const doAction = async (body: Record<string, unknown>) => {
107
+ try {
108
+ const res = await fetch(`${getBaseUrl()}/trinity`, {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/json" },
111
+ body: JSON.stringify(body),
112
+ })
113
+ const data = await res.json()
114
+ if (data.ok) {
115
+ api.ui.toast({ variant: "success", message: data.result })
116
+ await poll()
117
+ } else {
118
+ api.ui.toast({ variant: "error", message: "Action failed" })
119
+ }
120
+ } catch {
121
+ api.ui.toast({ variant: "error", message: "vibeOS offline" })
122
+ }
123
+ }
124
+
125
+ const Slot = api.ui.Slot
126
+
127
+ api.slots.register((props: { session_id: string }) => {
128
+ const s = status()
129
+ const sv = savings()
130
+
131
+ if (error()) {
132
+ return (
133
+ <box flexDirection="column">
134
+ <Slot name="sidebar_title" session_id={props.session_id} title="vibeOS">
135
+ <text dim>vibeOS offline</text>
136
+ </Slot>
137
+ <Slot name="sidebar_content" session_id={props.session_id}>
138
+ <box flexDirection="column" padding={1}>
139
+ <text dim>vibeOS MCP not running</text>
140
+ <newline />
141
+ <text dim>ensure the server plugin is active</text>
142
+ </box>
143
+ </Slot>
144
+ <Slot name="sidebar_footer" session_id={props.session_id}>
145
+ <text dim>vibeOS MCP offline</text>
146
+ </Slot>
147
+ </box>
148
+ )
149
+ }
150
+
151
+ const activeSlot = s?.active_slot ?? "?"
152
+ const enabled = s?.enabled ?? false
153
+ const trendArrow = sv?.trend === "up" ? "^" : sv?.trend === "down" ? "v" : "-"
154
+ const delegation = (sv?.current_session?.delegation_usd ?? 0) + (sv?.lifetime?.delegation_usd ?? 0)
155
+ const cache = (sv?.current_session?.cache_usd ?? 0) + (sv?.lifetime?.cache_usd ?? 0)
156
+ const lifetime = (sv?.lifetime?.delegation_usd ?? 0) + (sv?.lifetime?.cache_usd ?? 0)
157
+ const missedC7 = sv?.lifetime?.missed_context7_usd ?? 0
158
+ const toolBreakdown = sv?.current_session?.tool_breakdown ?? {}
159
+ const topTools = Object.entries(toolBreakdown)
160
+ .sort(([, a], [, b]) => (b as number) - (a as number))
161
+ .slice(0, 5)
162
+ const trendColor = sv?.trend === "up" ? "green" : sv?.trend === "down" ? "red" : "yellow"
163
+ const shortModel = s?.current_model?.split("/")[1] ?? s?.current_model ?? "?"
164
+ const flowOn = s?.flow_enforcer ?? false
165
+ const tddOn = s?.tdd_enforcer ?? false
166
+ const backendConnected = s?.backend_connected ?? false
167
+ const lockLabel = s?.model_locked
168
+ ? `${s?.locked_slot ? `${s.locked_slot} ` : ""}${s?.locked_model ?? ""}`.trim()
169
+ : "off"
170
+
171
+ return (
172
+ <box flexDirection="column">
173
+ <Slot name="sidebar_title" session_id={props.session_id} title="vibeOS">
174
+ <box>
175
+ <text bold>vibeOS</text>
176
+ <text dim> | </text>
177
+ <text color={enabled ? "green" : "red"} bold>{activeSlot}</text>
178
+ <text> </text>
179
+ <text color={enabled ? "green" : "red"}>{enabled ? "." : "o"}</text>
180
+ </box>
181
+ </Slot>
182
+ <Slot name="sidebar_content" session_id={props.session_id}>
183
+ <box flexDirection="column" padding={1}>
184
+ <text dim bold>MODEL STATUS</text>
185
+ <newline />
186
+ <box>
187
+ <text bold={activeSlot === "brain"} color={activeSlot === "brain" ? "green" : undefined}>
188
+ {shortModel}
189
+ </text>
190
+ {activeSlot === "brain" && <text color="green"> active</text>}
191
+ </box>
192
+ <newline />
193
+ <box>
194
+ <text>Backend </text>
195
+ <text color={backendConnected ? "green" : "red"} bold>{backendConnected ? "ON" : "OFF"}</text>
196
+ </box>
197
+ <newline />
198
+ <box>
199
+ <text>Lock </text>
200
+ <text color={s?.model_locked ? "green" : "red"} bold>{s?.model_locked ? "ON" : "OFF"}</text>
201
+ {s?.model_locked && lockLabel && <text dim> {lockLabel}</text>}
202
+ </box>
203
+ <newline />
204
+ <text dim>---</text>
205
+ <newline />
206
+ <box>
207
+ <text>Flow </text>
208
+ <text color={flowOn ? "green" : "red"} bold>{flowOn ? "ON" : "OFF"}</text>
209
+ </box>
210
+ <newline />
211
+ <box>
212
+ <text>TDD </text>
213
+ <text color={tddOn ? "green" : "red"} bold>{tddOn ? "ON" : "OFF"}</text>
214
+ {tddOn && s?.tdd_strict && <text dim> strict</text>}
215
+ </box>
216
+ <newline />
217
+ <box>
218
+ <text>Enforce </text>
219
+ <text color={s?.enforce ? "green" : "red"} bold>{s?.enforce ? "ON" : "OFF"}</text>
220
+ </box>
221
+ <newline />
222
+ <text dim>---</text>
223
+ <newline />
224
+ <box>
225
+ <text>Thinking: </text>
226
+ <text>{s?.thinking ?? "?"}</text>
227
+ </box>
228
+ <newline />
229
+ <newline />
230
+ <text dim bold>SAVINGS</text>
231
+ <newline />
232
+ <box>
233
+ <text bold>Saved: </text>
234
+ <text bold color={trendColor}>${lifetime.toFixed(2)} {trendArrow}</text>
235
+ </box>
236
+ <newline />
237
+ <box><text> Delegation: </text><text>${delegation.toFixed(2)}</text></box>
238
+ <newline />
239
+ <box><text> Cache: </text><text>${cache.toFixed(2)}</text></box>
240
+ <newline />
241
+ <box><text> C7 missed: </text><text>${missedC7.toFixed(2)}</text></box>
242
+ <newline />
243
+ <text dim>---</text>
244
+ <newline />
245
+ <box><text>Rate: </text><text>${sv?.savings_rate_per_hour?.toFixed(2) ?? "0.00"}/hr</text></box>
246
+ <newline />
247
+ <box><text>Warns: </text><text>{sv?.current_session?.warns_count ?? 0}</text></box>
248
+ <newline />
249
+ <text dim>---</text>
250
+ <newline />
251
+ <text dim>Tool split:</text>
252
+ <newline />
253
+ {topTools.map(([tool, val]) => (
254
+ <newline />
255
+ <text dim bold>TODOS</text>
256
+ <newline />
257
+ <box>
258
+ <text>Pending: </text>
259
+ <text color={s?.todos?.pending > 0 ? "yellow" : "green"} bold>
260
+ {s?.todos?.pending ?? 0}
261
+ </text>
262
+ <text> / {s?.todos?.total ?? 0}</text>
263
+ </box>
264
+ <newline />
265
+
266
+ <>
267
+ <box>
268
+ <text> {tool.padEnd(8)}</text>
269
+ <text>${(val as number).toFixed(2)}</text>
270
+ </box>
271
+ <newline />
272
+ </>
273
+ ))}
274
+ <newline />
275
+ <text dim bold>CONTROLS</text>
276
+ <newline />
277
+ <box>
278
+ <text
279
+ onClick={() => doAction({ action: "set", slot: "brain", level: null })}
280
+ color={activeSlot === "brain" ? "green" : "dim"}
281
+ bold={activeSlot === "brain"}
282
+ >[brain]</text>
283
+ <text> </text>
284
+ <text
285
+ onClick={() => doAction({ action: "set", slot: "medium", level: null })}
286
+ color={activeSlot === "medium" ? "green" : "dim"}
287
+ bold={activeSlot === "medium"}
288
+ >[medium]</text>
289
+ <text> </text>
290
+ <text
291
+ onClick={() => doAction({ action: "set", slot: "cheap", level: null })}
292
+ color={activeSlot === "cheap" ? "green" : "dim"}
293
+ bold={activeSlot === "cheap"}
294
+ >[cheap]</text>
295
+ </box>
296
+ <newline />
297
+ <box>
298
+ <text
299
+ onClick={() => doAction({ action: "flow", slot: flowOn ? "off" : "on", level: null })}
300
+ color={flowOn ? "green" : "red"} bold
301
+ >[Flow {flowOn ? "ON" : "OFF"}]</text>
302
+ </box>
303
+ <newline />
304
+ <box>
305
+ <text
306
+ onClick={() => doAction({ action: "tdd", slot: tddOn ? "off" : "on", level: null })}
307
+ color={tddOn ? "green" : "red"} bold
308
+ >[TDD {tddOn ? "ON" : "OFF"}]</text>
309
+ </box>
310
+ <newline />
311
+ <box>
312
+ <text
313
+ onClick={() => doAction({ action: "enforce", slot: s?.enforce ? "off" : "on", level: null })}
314
+ color={s?.enforce ? "green" : "red"} bold
315
+ >[Enforce {s?.enforce ? "ON" : "OFF"}]</text>
316
+ </box>
317
+ <newline />
318
+ <box>
319
+ <text
320
+ onClick={() => doAction({ action: "disable", slot: null, level: null })}
321
+ color="red" bold
322
+ >[Disable]</text>
323
+ </box>
324
+ </box>
325
+ </Slot>
326
+ <Slot name="sidebar_footer" session_id={props.session_id}>
327
+ <box>
328
+ <text dim>Saved </text>
329
+ <text color={trendColor}>{lifetime.toFixed(2)}</text>
330
+ <text dim> {trendArrow} </text>
331
+ <text>{sv?.savings_rate_per_hour?.toFixed(2) ?? "0.00"}/hr</text>
332
+ <text dim> | {sv?.current_session?.warns_count ?? 0} warns</text>
333
+ </box>
334
+ </Slot>
335
+ </box>
336
+ )
337
+ })
338
+ }
339
+
340
+ export default { tui: plugin }
package/CHANGELOG.md CHANGED
@@ -1,3 +1,6 @@
1
+ ## 0.19.2
2
+ - fix: export server and tui entrypoints
3
+
1
4
  ## 0.19.1
2
5
  - fix: make README and runtime self-contained
3
6
  - test: add 59 integration + e2e tests for cross-module behavior and user workflows
package/README.md CHANGED
@@ -48,6 +48,8 @@ If you keep a local checkout of the plugin, point OpenCode at the built file ins
48
48
 
49
49
  Restart OpenCode Desktop after changing the config.
50
50
 
51
+ The package also exposes `vibeostheog/server` and `vibeostheog/tui` for integrations that need the MCP server or sidebar plugin entrypoints directly.
52
+
51
53
  ## Common Npm Commands
52
54
 
53
55
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeostheog",
3
- "version": "0.19.1",
3
+ "version": "0.19.2",
4
4
  "description": "Cost-aware delegation enforcer for OpenCode. Tracks model usage, routes Task subagents to cheaper tiers, surfaces cumulative savings in chat. Includes research audit, reporting framework, project memory, progressive scratchpad decadence, and trinity CLI for brain/medium/cheap slot switching.",
5
5
  "scripts": {
6
6
  "release": "node scripts/release.mjs",
@@ -26,7 +26,9 @@
26
26
  },
27
27
  "type": "module",
28
28
  "exports": {
29
- ".": "./src/index.js"
29
+ ".": "./src/index.js",
30
+ "./server": "./src/lib/vibeos-mcp-server.js",
31
+ "./tui": "./.opencode/plugins/vibeOS-tui.tsx"
30
32
  },
31
33
  "keywords": [
32
34
  "opencode",
@@ -46,6 +48,8 @@
46
48
  },
47
49
  "files": [
48
50
  "src/index.js",
51
+ "src/lib/vibeos-mcp-server.js",
52
+ ".opencode/plugins/vibeOS-tui.tsx",
49
53
  "scripts/deploy.mjs",
50
54
  "model-tiers.sample.json",
51
55
  "README.md",
package/src/index.js CHANGED
@@ -545,7 +545,7 @@ function computeSessionMetrics(state, sessionId) {
545
545
  };
546
546
  }
547
547
 
548
- // src/lib/vibeos-mcp-server.ts
548
+ // src/lib/vibeos-mcp-server.js
549
549
  import http from "node:http";
550
550
  import { parse as parseUrl } from "node:url";
551
551
  import { createReadStream, existsSync as existsSync2, statSync as statSync2 } from "node:fs";
@@ -594,7 +594,8 @@ function resolveDashboardDir() {
594
594
  join2(_MCP_DIR, "dashboard", "dist")
595
595
  ];
596
596
  for (const p of c) {
597
- if (existsSync2(join2(p, "index.html"))) return p;
597
+ if (existsSync2(join2(p, "index.html")))
598
+ return p;
598
599
  }
599
600
  return c[0];
600
601
  }
@@ -820,8 +821,10 @@ function createMcpServer(deps) {
820
821
  };
821
822
  return {
822
823
  async start(port) {
823
- if (server2) return server2;
824
- if (startPromise) return startPromise;
824
+ if (server2)
825
+ return server2;
826
+ if (startPromise)
827
+ return startPromise;
825
828
  startPromise = new Promise((resolve, reject) => {
826
829
  const srv = http.createServer((req, res) => {
827
830
  void handler(req, res);
@@ -839,8 +842,10 @@ function createMcpServer(deps) {
839
842
  }
840
843
  },
841
844
  async close() {
842
- if (!server2) return;
843
- if (closePromise) return closePromise;
845
+ if (!server2)
846
+ return;
847
+ if (closePromise)
848
+ return closePromise;
844
849
  closePromise = new Promise((resolve, reject) => {
845
850
  server2?.close((err) => err ? reject(err) : resolve());
846
851
  });
@@ -3620,7 +3625,7 @@ var MODEL_USD_PER_TURN = {
3620
3625
  "haiku": 22e-4,
3621
3626
  // ── DeepSeek (OC platform + OpenRouter) ──────────────────
3622
3627
  "deepseek/deepseek-v4-pro": 57e-5,
3623
- "deepseek/deepseek-v4-flash": 0.00013,
3628
+ "deepseek/deepseek-v4-flash": 182e-6,
3624
3629
  "deepseek/deepseek-chat": 182e-6,
3625
3630
  "deepseek-chat": 182e-6,
3626
3631
  "deepseek/deepseek-v3": 182e-6,
@@ -0,0 +1,320 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 vibeOS <https://github.com/DrunkkToys/vibeOS>
3
+ import http from "node:http";
4
+ import { parse as parseUrl } from "node:url";
5
+ import { createReadStream, existsSync, statSync } from "node:fs";
6
+ import { extname, join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ const MIME_MAP = {
9
+ ".html": "text/html; charset=utf-8",
10
+ ".js": "application/javascript; charset=utf-8",
11
+ ".css": "text/css; charset=utf-8",
12
+ ".json": "application/json; charset=utf-8",
13
+ ".png": "image/png",
14
+ ".ico": "image/x-icon",
15
+ };
16
+ function json(res, statusCode, data) {
17
+ res.statusCode = statusCode;
18
+ res.setHeader("Content-Type", "application/json");
19
+ res.end(JSON.stringify(data));
20
+ }
21
+ function parseBody(req) {
22
+ return new Promise((resolve, reject) => {
23
+ let raw = "";
24
+ req.on("data", (chunk) => {
25
+ raw += String(chunk || "");
26
+ if (raw.length > 1024 * 1024) {
27
+ reject(new Error("payload too large"));
28
+ }
29
+ });
30
+ req.on("end", () => {
31
+ if (!raw.trim()) {
32
+ resolve({});
33
+ return;
34
+ }
35
+ try {
36
+ resolve(JSON.parse(raw));
37
+ }
38
+ catch {
39
+ reject(new Error("invalid request"));
40
+ }
41
+ });
42
+ req.on("error", reject);
43
+ });
44
+ }
45
+ const _MCP_FILENAME = fileURLToPath(import.meta.url);
46
+ const _MCP_DIR = dirname(_MCP_FILENAME);
47
+ function resolveDashboardDir() {
48
+ const c = [
49
+ join(_MCP_DIR, "dashboard", "dist"),
50
+ ];
51
+ for (const p of c) {
52
+ if (existsSync(join(p, "index.html")))
53
+ return p;
54
+ }
55
+ return c[0];
56
+ }
57
+ const DASHBOARD_DIR = resolveDashboardDir();
58
+ const BACKEND_HEALTH_URL = process.env.VIBEOS_BACKEND_HEALTH_URL || "http://127.0.0.1:3000/health";
59
+ const BACKEND_HEALTH_TTL_MS = 5_000;
60
+ let backendHealth = { ok: null, checkedAt: 0 };
61
+ async function probeBackendHealth(force = false) {
62
+ const now = Date.now();
63
+ if (!force && backendHealth.ok !== null && (now - backendHealth.checkedAt) < BACKEND_HEALTH_TTL_MS) {
64
+ return backendHealth.ok;
65
+ }
66
+ try {
67
+ const ctl = new AbortController();
68
+ const timer = setTimeout(() => ctl.abort(), 1500);
69
+ const res = await fetch(BACKEND_HEALTH_URL, { signal: ctl.signal });
70
+ clearTimeout(timer);
71
+ backendHealth = { ok: res.ok, checkedAt: now };
72
+ return res.ok;
73
+ }
74
+ catch {
75
+ backendHealth = { ok: false, checkedAt: now };
76
+ return false;
77
+ }
78
+ }
79
+ function sendFile(res, fp) {
80
+ if (!existsSync(fp)) {
81
+ res.statusCode = 404;
82
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
83
+ res.end("not found");
84
+ return;
85
+ }
86
+ const ext = extname(fp).toLowerCase();
87
+ const mime = MIME_MAP[ext] || "application/octet-stream";
88
+ const st = statSync(fp);
89
+ res.statusCode = 200;
90
+ res.setHeader("Content-Type", mime);
91
+ res.setHeader("Content-Length", st.size);
92
+ res.setHeader("Cache-Control", "no-cache");
93
+ const s = createReadStream(fp);
94
+ s.pipe(res);
95
+ s.on("error", () => { res.statusCode = 500; res.end(); });
96
+ }
97
+ function serveDashboard(res, p) {
98
+ const idx = join(DASHBOARD_DIR, "index.html");
99
+ let fp = join(DASHBOARD_DIR, p === "/" ? "index.html" : p);
100
+ if (existsSync(fp) && statSync(fp).isFile()) {
101
+ sendFile(res, fp);
102
+ return;
103
+ }
104
+ if (existsSync(idx)) {
105
+ sendFile(res, idx);
106
+ return;
107
+ }
108
+ res.statusCode = 404;
109
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
110
+ res.end("not found");
111
+ }
112
+ export function createMcpServer(deps) {
113
+ let server = null;
114
+ let startPromise = null;
115
+ let closePromise = null;
116
+ const handler = async (req, res) => {
117
+ try {
118
+ const method = (req.method || "GET").toUpperCase();
119
+ const parsed = parseUrl(req.url || "/", true);
120
+ const path = parsed.pathname || "/";
121
+ if (method === "GET" && path === "/status") {
122
+ const state = deps.getState();
123
+ const ok = await probeBackendHealth();
124
+ json(res, 200, { ...state, backend_connected: ok === true, backend_health_url: BACKEND_HEALTH_URL });
125
+ return;
126
+ }
127
+ if (method === "GET" && path === "/savings") {
128
+ json(res, 200, deps.getSavings());
129
+ return;
130
+ }
131
+ if (method === "GET" && path === "/todos") {
132
+ json(res, 200, deps.getTodos());
133
+ return;
134
+ }
135
+ if (method === "GET" && path === "/sessions") {
136
+ const state = deps.getState();
137
+ const sessionsMap = state?.sessions_raw || {};
138
+ const sessions = Object.entries(sessionsMap).map(([id, ses]) => ({
139
+ id,
140
+ started: ses?.started || null,
141
+ cost_usd: Number(ses?.cost_usd ?? 0) || 0,
142
+ delegation_savings_usd: Array.isArray(ses?.warns)
143
+ ? ses.warns.reduce((sum, w) => sum + (Number(w?.est_savings_usd ?? 0) || 0), 0)
144
+ : ses?.total_savings_usd || 0,
145
+ cache_savings_usd: Number(ses?.cache_savings_usd ?? 0) || 0,
146
+ warns_count: Array.isArray(ses?.warns) ? ses.warns.length : 0,
147
+ }));
148
+ json(res, 200, { sessions, total_sessions: sessions.length });
149
+ return;
150
+ }
151
+ if (method === "GET" && path === "/sessions/current") {
152
+ json(res, 200, deps.getSessionMetrics(deps.getCurrentSessionId()));
153
+ return;
154
+ }
155
+ if (method === "GET" && path === "/reports") {
156
+ try {
157
+ const query = parsed.query;
158
+ const type = typeof query.type === "string" ? query.type : undefined;
159
+ const project = typeof query.project === "string" ? query.project : undefined;
160
+ const hoursRaw = query.hours;
161
+ const hours = hoursRaw != null ? Number(hoursRaw) : undefined;
162
+ const fingerprint = typeof query.fingerprint === "string" ? query.fingerprint : undefined;
163
+ const reports = deps.listReports({ type, project, hours: Number.isFinite(hours) ? hours : undefined, fingerprint });
164
+ json(res, 200, reports);
165
+ }
166
+ catch (err) {
167
+ const error = err;
168
+ if (error?.status === 404) {
169
+ json(res, 404, { error: "not found", status: 404 });
170
+ return;
171
+ }
172
+ throw err;
173
+ }
174
+ return;
175
+ }
176
+ if (method === "GET" && path.startsWith("/reports/")) {
177
+ const id = decodeURIComponent(path.replace(/^\/reports\//, "")).trim();
178
+ const report = deps.readReport(id);
179
+ if (!report) {
180
+ json(res, 404, { error: "not found", status: 404 });
181
+ return;
182
+ }
183
+ json(res, 200, report);
184
+ return;
185
+ }
186
+ if (method === "GET" && path === "/diagnose") {
187
+ json(res, 200, deps.runDiagnose());
188
+ return;
189
+ }
190
+ if (method === "GET" && path === "/project") {
191
+ json(res, 200, deps.runProject());
192
+ return;
193
+ }
194
+ if (method === "POST" && path === "/trinity") {
195
+ let body;
196
+ try {
197
+ body = await parseBody(req);
198
+ }
199
+ catch {
200
+ json(res, 400, { error: "invalid request", status: 400 });
201
+ return;
202
+ }
203
+ const action = body?.action;
204
+ const slot = body?.slot;
205
+ const level = body?.level;
206
+ if (!action || typeof action !== "string") {
207
+ json(res, 400, { error: "invalid request", status: 400 });
208
+ return;
209
+ }
210
+ const result = await deps.runTrinity(action, { slot, level });
211
+ const txt = typeof result === "string" ? result : JSON.stringify(result);
212
+ const ok = !(txt.startsWith("❌") || txt.toLowerCase().includes("unknown action"));
213
+ json(res, ok ? 200 : 400, ok ? { ok: true, result } : { ok: false, error: txt });
214
+ return;
215
+ }
216
+ if (method === "POST" && path === "/research-audit") {
217
+ let body;
218
+ try {
219
+ body = await parseBody(req);
220
+ }
221
+ catch {
222
+ json(res, 400, { error: "invalid request", status: 400 });
223
+ return;
224
+ }
225
+ const hours = Number(body?.hours ?? 24);
226
+ const report = deps.runResearchAudit(Number.isFinite(hours) ? hours : 24);
227
+ json(res, 200, report);
228
+ return;
229
+ }
230
+ if (method === "POST" && path === "/reports") {
231
+ let body;
232
+ try {
233
+ body = await parseBody(req);
234
+ }
235
+ catch {
236
+ json(res, 400, { error: "invalid request", status: 400 });
237
+ return;
238
+ }
239
+ if (!body || typeof body !== "object") {
240
+ json(res, 400, { error: "invalid request", status: 400 });
241
+ return;
242
+ }
243
+ const id = deps.saveReport({
244
+ type: "manual",
245
+ summary: body.summary || "",
246
+ findings: body.findings || [],
247
+ metrics: body.metrics || {},
248
+ narrative: body.narrative || "",
249
+ tags: Array.isArray(body.tags) ? body.tags : [],
250
+ });
251
+ if (!id) {
252
+ json(res, 500, { error: "failed to save report", status: 500 });
253
+ return;
254
+ }
255
+ json(res, 200, { ok: true, id });
256
+ return;
257
+ }
258
+ if (method === "POST" && path === "/sessions/checkout") {
259
+ const result = deps.generateSessionCheckout();
260
+ json(res, 200, result);
261
+ return;
262
+ }
263
+ if (method === "GET" && path === "/") {
264
+ serveDashboard(res, "/");
265
+ return;
266
+ }
267
+ if (method === "GET" && (path.startsWith("/assets/") || path.startsWith("/favicon") || path.endsWith(".js") || path.endsWith(".css") || path.endsWith(".html"))) {
268
+ serveDashboard(res, path);
269
+ return;
270
+ }
271
+ if (method === "GET" && path === "/health") {
272
+ json(res, 200, { ok: true });
273
+ return;
274
+ }
275
+ json(res, 404, { error: "not found", status: 404 });
276
+ }
277
+ catch (err) {
278
+ const message = err instanceof Error ? err.message : "server error";
279
+ json(res, 500, { error: message, status: 500 });
280
+ }
281
+ };
282
+ return {
283
+ async start(port) {
284
+ if (server)
285
+ return server;
286
+ if (startPromise)
287
+ return startPromise;
288
+ startPromise = new Promise((resolve, reject) => {
289
+ const srv = http.createServer((req, res) => { void handler(req, res); });
290
+ srv.once("error", reject);
291
+ srv.listen(port, () => {
292
+ server = srv;
293
+ resolve(srv);
294
+ });
295
+ });
296
+ try {
297
+ return await startPromise;
298
+ }
299
+ finally {
300
+ startPromise = null;
301
+ }
302
+ },
303
+ async close() {
304
+ if (!server)
305
+ return;
306
+ if (closePromise)
307
+ return closePromise;
308
+ closePromise = new Promise((resolve, reject) => {
309
+ server?.close(err => err ? reject(err) : resolve());
310
+ });
311
+ try {
312
+ await closePromise;
313
+ }
314
+ finally {
315
+ server = null;
316
+ closePromise = null;
317
+ }
318
+ },
319
+ };
320
+ }