diff --git a/bun.lock b/bun.lock index c045070..125cbed 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@four-bytes/four-opencode-brain", "dependencies": { - "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.5.1", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.6.0", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", @@ -81,7 +81,7 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@four-bytes/opencode-plugin-lib": ["@four-bytes/opencode-plugin-lib@github:four-bytes/four-opencode-plugin-lib#4e94bfc", { "peerDependencies": { "@opencode-ai/plugin": ">=1.16.0", "@opentui/solid": "^0.4.1", "solid-js": "^1.9.13" } }, "four-bytes-four-opencode-plugin-lib-4e94bfc", "sha512-D9P+bkheWLhMRERdREDu0hrI957R/X9xLXozMEdiJeAOgoUspJ+klYDFWPCmWIC2CMs/wzjirbARRWbH2Yl/uA=="], + "@four-bytes/opencode-plugin-lib": ["@four-bytes/opencode-plugin-lib@github:four-bytes/four-opencode-plugin-lib#75c3e9a", { "peerDependencies": { "@opencode-ai/plugin": ">=1.16.0", "@opentui/solid": "^0.4.1", "solid-js": "^1.9.13" } }, "four-bytes-four-opencode-plugin-lib-75c3e9a", "sha512-+8whUKFaCD7JsxYQOl+o/KtpTseyn3sj2Iq2ZNItxpAJs7gbIarqG1djLRO5j3QoOTgw4vLtVRunjvgwRKTpKQ=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], diff --git a/package.json b/package.json index bc9cd08..f406c96 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "four-bytes" ], "dependencies": { - "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.5.1", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.6.0", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", diff --git a/src/status.ts b/src/status.ts index 6753a67..7dd2413 100644 --- a/src/status.ts +++ b/src/status.ts @@ -2,6 +2,7 @@ import { writeFileSync, mkdirSync, existsSync } from "fs"; import { createHash } from "crypto"; import { homedir } from "os"; import { join } from "path"; +import { AsyncLocalStorage } from "node:async_hooks"; import type { PluginInput } from "@opencode-ai/plugin"; import { BusClient } from "@four-bytes/opencode-plugin-lib"; import type { BrainStatusEvent } from "./event-bus"; @@ -25,11 +26,24 @@ const _state = { current: {} as Record }; _state.current = { status: "init", statusText: "", version: "" }; let _version = ""; let _sessionId = ""; -let _channel = "brain/status"; + +// ALS stores the session ID for the duration of each tool execute. +// write() reads from here first, so concurrent sessions never overwrite each other's channel. +const _sessionAls = new AsyncLocalStorage(); + +/** + * Run fn in an ALS context bound to sessionId. + * All updateStatus calls inside fn publish to brain/{sessionId}. + * Outside any withSessionId context (e.g. startup, auto-ingest), they publish to brain/status. + */ +export function withSessionId(id: string, fn: () => Promise): Promise { + return _sessionAls.run(id, fn); +} let _client: PluginInput["client"] | null = null; let _server: ReturnType | null = null; let _port = 0; +let _brainBus: BusClient | null = null; let _busPromise: Promise | null = null; /** Initialize with client for toast support */ @@ -41,7 +55,6 @@ export function initVersion(v: string): void { export function setSessionId(id: string): void { if (id === _sessionId) return; _sessionId = id; - _channel = `brain/${id}`; if (_port > 0) { try { @@ -116,11 +129,18 @@ export function stopStatusServer(): void { function write(data: Record): void { _state.current = { ..._state.current, ...data }; - const payload = { ..._state.current, version: _version, sessionId: _sessionId || undefined } as BrainStatusEvent; + // ALS-stored session ID wins over global (prevents cross-session channel overwrite). + // If no ALS context (startup, auto-ingest fire-and-forget), use the unscoped "brain" service. + const sid = _sessionAls.getStore() ?? ""; + const payload = { ..._state.current, version: _version, sessionId: sid || undefined } as BrainStatusEvent; - // Real-time push via plugin bus (HTTP fallback still serves status endpoint) + // Real-time push via scoped plugin bus (HTTP fallback still serves status endpoint) getBus() - .then((bus) => bus.publish(_channel, payload)) + .then(async (bus) => { + const scoped = bus.forService("brain"); + const target = sid ? scoped.forSession(sid) : scoped; + await target.publish("status", payload); + }) .catch((err) => { console.warn("[brain] Bus publish failed:", (err as Error).message); }); diff --git a/src/tui.tsx b/src/tui.tsx index 1e8f550..9121d1d 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,41 +1,41 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, createEffect, onMount, onCleanup } from "solid-js"; +import { createSignal, onMount, onCleanup, Show } from "solid-js"; import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"; import type { RGBA } from "@opentui/core"; import { BusTui } from "@four-bytes/opencode-plugin-lib/tui"; import { ProgressBar } from "@four-bytes/opencode-plugin-lib/tui-components"; import type { BrainStatusEvent } from "./event-bus"; import { Spinner } from "./spinner"; -function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi; sessionId?: string }) { - const [indicator, setIndicator] = createSignal("•"); - const [status, setStatus] = createSignal("connecting…"); + +function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; sessionId?: string }) { + const [statusText, setStatusText] = createSignal("connecting…"); const [version, setVersion] = createSignal(""); const [current, setCurrent] = createSignal(0); const [total, setTotal] = createSignal(0); - const [pct, setPct] = createSignal(0); const [fg, setFg] = createSignal(""); const [busy, setBusy] = createSignal(false); - let pulse = 0; + const [hasError, setHasError] = createSignal(false); let lastPoll = Date.now(); const theme = () => props.api.theme.current; const connecting = () => (!version() || Date.now() - lastPoll > 2000) && !busy(); + const showProgress = () => busy() && current() > 0 && total() > 0; const handleStatus = (data: BrainStatusEvent) => { try { lastPoll = Date.now(); setVersion(data.version ?? ""); - pulse++; + setHasError(false); if (data.status === "error") { setBusy(false); - setIndicator("•"); - setStatus(data.error || data.statusText || "error occurred"); + setStatusText(data.error || data.statusText || "error occurred"); setFg(theme().error); + setHasError(true); } else if (data.status === "init") { setBusy(true); - setStatus(data.statusText ?? "initializing…"); + setStatusText(data.statusText ?? "initializing…"); setCurrent(0); setTotal(0); setFg(theme().warning); @@ -43,119 +43,92 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi; sessionI setBusy(true); setCurrent(data.current ?? 0); setTotal(data.total ?? 0); - setStatus(data.statusText ?? "working…"); - setFg(pulse % 2 === 0 ? theme().warning : theme().accent); + setStatusText(data.statusText ?? "working…"); + setFg(theme().warning); } else { setCurrent(0); setTotal(0); setBusy(false); - setIndicator("•"); - setStatus("ready"); + setStatusText("ready"); setFg(theme().success); } } catch { setBusy(false); - setIndicator("•"); - setStatus("error occurred"); + setStatusText("error occurred"); setFg(theme().error); + setHasError(true); } }; onMount(() => { - // Bus is a signal so createEffect below can react when BusTui resolves. const [bus, setBus] = createSignal(null); let unsub: (() => void) | null = null; - let sessionUnsub: (() => void) | null = null; let unmounted = false; onCleanup(() => { unmounted = true; unsub?.(); - sessionUnsub?.(); bus()?.close(); }); - // Real-time WebSocket subscription via plugin bus. - // HTTP fallback removed — bus is the only transport; status server stays - // for any external probes (e.g. /status endpoint) but TUI never polls it. BusTui.connect() .then((b) => { if (unmounted) { b.close(); return; } setBus(b); - // Always subscribe to brain/status — server publishes here during ingest, - // before any chat message creates a session. Once sessionId is known, - // also subscribe to the per-session channel (server switches to it - // after first chat.message). Prevents missing pre-session status updates. - unsub = b.subscribe("brain/status", (envelope) => { - const data = envelope.payload as BrainStatusEvent & { sessionId?: string }; - // Only process if no session ID OR matches current session - if (data.sessionId && props.sessionId && data.sessionId !== props.sessionId) return; - handleStatus(data); + // Scoped subscription: forService("brain") + forSession(sid) replaces + // the old brain/{sid} channel. No sessionId filter needed — the bus + // only delivers events for the scoped session (or unscoped when sid missing). + const scoped = b.forService("brain"); + const brainBus = props.sessionId ? scoped.forSession(props.sessionId) : scoped; + unsub = brainBus.subscribe("status", (envelope) => { + handleStatus(envelope.payload as BrainStatusEvent); }); }) .catch((err) => { console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); }); - - // React to props.sessionId changes — sessionId is set later by the host - // (after first chat.message), so it is often undefined at mount time. - // Tracks bus() so the effect re-runs once BusTui.connect() resolves. - createEffect(() => { - const sid = props.sessionId; - const b = bus(); - sessionUnsub?.(); - if (!sid || !b) return; - sessionUnsub = b.subscribe(`brain/${sid}`, (envelope) => { - const data = envelope.payload as BrainStatusEvent & { sessionId?: string }; - if (data.sessionId && data.sessionId !== sid) return; - handleStatus(data); - }); - }); }); - const StatusRow = () => ( - - 🧠 {version()} - {busy() ? : {indicator()}} - {connecting() ? "connecting…" : status()} - {busy() && current() > 0 && total() > 0 && ( - - )} - {busy() && current() > 0 && total() > 0 && ( - - )} - - ); + const indicatorColor = () => connecting() ? theme().error : (hasError() ? theme().error : fg()); + const textColor = () => connecting() ? theme().error : theme().textMuted; return ( - {props.centered ? ( - - - - Brain - - - - - ) : ( - + + {/* Sidebar: header row + status row (two-column: text left, bar right) */} + Brain 🧠 {version()} - - {busy() ? : {indicator()}} - {connecting() ? "connecting…" : status()} - {busy() && current() > 0 && total() > 0 && ( - - )} - {busy() && current() > 0 && total() > 0 && ( - - )} + + + {busy() ? : } + {connecting() ? "connecting…" : statusText()} + + {current()}/{total()} + + + + + + + - )} + + + {/* Home: single compact row, centered */} + + 🧠 {version()} + {busy() ? : } + {connecting() ? "connecting…" : statusText()} + + + + + + ); } @@ -164,10 +137,10 @@ export { BrainStatusBar }; const tui: TuiPlugin = (api) => { api.slots.register({ - order: 60, // below deepseek-meter (55) + order: 60, slots: { - sidebar_content: (_ctx: any, props: any) => , - home_bottom: () => , + sidebar_content: (_ctx: any, props: any) => , + home_bottom: () => , }, }); return Promise.resolve();