diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index 9d3ce19..73e9380 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -53,7 +53,7 @@ function calculateIngestTimeout(fileCount: number): number { } /** Unified status updates — see src/status.ts */ -import { updateStatus, initStatus, initVersion } from "./status"; +import { updateStatus, initStatus, initVersion, stopStatusServer } from "./status"; @@ -738,6 +738,9 @@ const _serverPlugin = async (input: PluginInput) => { brain_kb_search, brain_kb_stats, }, + dispose: async () => { + stopStatusServer(); + }, }; }; diff --git a/src/shared.ts b/src/shared.ts index b6a1d82..e958834 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -1,8 +1 @@ -import { createHash } from "crypto"; -import { homedir } from "os"; -import { join } from "path"; - -export function getBrainStatusFile(directory: string): string { - const hash = createHash("md5").update(directory).digest("hex").slice(0, 12); - return join(homedir(), ".cache", "opencode", "brain", `status-${hash}.json`); -} +// shared.ts — utilities extracted; HTTP status endpoint replaces file-based communication diff --git a/src/status.ts b/src/status.ts index 1514354..94e9dfd 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,7 +1,9 @@ import { writeFileSync, mkdirSync, existsSync } from "fs"; +import { createHash } from "crypto"; +import { homedir } from "os"; +import { join } from "path"; import type { PluginInput } from "@opencode-ai/plugin"; import { brainBus, type BrainStatusEvent } from "./event-bus"; -import { getBrainStatusFile } from "./shared"; export type StatusState = "busy" | "success" | "warning" | "error" | "ready"; @@ -26,7 +28,8 @@ let currentStatus: Record = { phase: "init", version: "" }; let _version = ""; let _client: PluginInput["client"] | null = null; -let _statusFile = ""; +let _server: ReturnType | null = null; +let _port = 0; /** Initialize with client for toast support */ export function initVersion(v: string): void { @@ -36,20 +39,55 @@ export function initVersion(v: string): void { export function initStatus(client: PluginInput["client"], directory: string): void { _client = client; - _statusFile = getBrainStatusFile(directory); + startStatusServer(directory); +} + +export function startStatusServer(directory: string): void { + if (_server) return; + + try { + _server = Bun.serve({ + port: 0, // OS assigns free port — no collision possible + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/status") { + const payload = { ...currentStatus, version: _version, updated: Date.now() }; + return new Response(JSON.stringify(payload), { + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + } catch (err) { + // Fallback: if Bun.serve fails (e.g., out of FDs), TUI will use default state + console.error("[brain] Failed to start status server:", err); + return; + } + + _port = _server.port; + + // Write port to session-scoped discovery file + try { + const hash = createHash("md5").update(directory).digest("hex").slice(0, 12); + const portFile = join(homedir(), ".cache", "opencode", "brain", `status-port-${hash}.json`); + const dir = portFile.replace(/\/[^/]+$/, ""); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(portFile, JSON.stringify({ port: _port })); + } catch { /* silently ignore */ } +} + +export function stopStatusServer(): void { + if (_server) { + _server.stop(); + _server = null; + _port = 0; + } } function write(data: Record): void { currentStatus = { ...currentStatus, ...data }; - const payload = { ...currentStatus, version: _version, updated: Date.now() }; - brainBus.emit("status", payload as BrainStatusEvent); - try { - if (_statusFile) { - const dir = _statusFile.replace(/\/[^/]+$/, ""); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(_statusFile, JSON.stringify(payload)); - } - } catch { /* never crash on status file failure */ } + brainBus.emit("status", { ...currentStatus, version: _version } as BrainStatusEvent); } /** diff --git a/src/tui.tsx b/src/tui.tsx index e30e1cb..90c1dee 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -4,8 +4,10 @@ import { createSignal, onMount, onCleanup } from "solid-js"; import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; import type { RGBA } from "@opentui/core"; import { brainBus, type BrainStatusEvent } from "./event-bus"; -import { getBrainStatusFile } from "./shared"; import { Spinner } from "./spinner"; +import { createHash } from "crypto"; +import { homedir } from "os"; +import { join } from "path"; function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { const [indicator, setIndicator] = createSignal("•"); @@ -81,15 +83,36 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) { onMount(() => { const unsub = brainBus.on("status", handleStatus); - const poll = async () => { + + // Resolve port from discovery file, then poll HTTP endpoint + let statusUrl = ""; + const hash = createHash("md5").update(props.api.state.path.directory).digest("hex").slice(0, 12); + const portFile = join(homedir(), ".cache", "opencode", "brain", `status-port-${hash}.json`); + + const resolvePort = async () => { try { - const statusFile = getBrainStatusFile(props.api.state.path.directory); - const file = Bun.file(statusFile); + const file = Bun.file(portFile); if (!(await file.exists())) return; const data = await file.json(); + if (data.port) { + statusUrl = `http://127.0.0.1:${data.port}/status`; + } + } catch { /* port file not ready yet */ } + }; + + const poll = async () => { + if (!statusUrl) { + await resolvePort(); + if (!statusUrl) return; + } + try { + const res = await fetch(statusUrl); + if (!res.ok) return; + const data = await res.json(); handleStatus(data as BrainStatusEvent); - } catch { /* silent */ } + } catch { /* server not ready */ } }; + poll(); const timer = setInterval(poll, 200); onCleanup(() => { unsub(); clearInterval(timer); });