Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"four-bytes"
],
"dependencies": {
"@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.3.0",
"@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.5.0",
"@opencode-ai/plugin": "1.16.2",
"@opentui/core": "0.3.2",
"@opentui/solid": "0.3.2",
Expand Down
17 changes: 9 additions & 8 deletions src/four-opencode-brain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function calculateIngestTimeout(fileCount: number): number {
}

/** Unified status updates — see src/status.ts */
import { updateStatus, initStatus, initVersion, stopStatusServer, toast } from "./status";
import { updateStatus, initStatus, initVersion, setSessionId, stopStatusServer, toast } from "./status";



Expand Down Expand Up @@ -97,20 +97,20 @@ const _serverPlugin = async (input: PluginInput) => {
// Fire-and-forget — don't block plugin readiness
(async () => {
// Signal TUI we're scanning the directory tree
updateStatus("busy", { text: "scanning files...", total: 0 });
updateStatus("busy", { text: "scanning files…", total: 0 });

// Quick preliminary file count for toast + timeout calculation
let fileCount = 0;
try {
const walked = await resolveFiles(directory, true);
fileCount = walked.files.length;
updateStatus("busy", { text: `scanning files... ${fileCount}`, total: fileCount });
updateStatus("busy", { text: `scanning files… ${fileCount}`, total: fileCount });
const timeoutS = (calculateIngestTimeout(fileCount) / 1000).toFixed(0);
toast( `Indexing ${fileCount} files… (timeout: ${timeoutS}s)`, "info", "Brain 🧠");
updateStatus("busy", { text: `ingesting... 0/${fileCount}`, current: 0, total: fileCount });
updateStatus("busy", { text: `ingesting…`, current: 0, total: fileCount });
} catch {
toast( `Indexing ${project?.name ?? "project"}…`, "info", "Brain 🧠");
updateStatus("busy", { text: "ingesting..." });
updateStatus("busy", { text: "ingesting…" });
}

const ingestDb = initBrainDatabase();
Expand All @@ -123,7 +123,7 @@ const _serverPlugin = async (input: PluginInput) => {
project: directory,
progressCallback: ({ current, total }) => {
// Update status file every tick so TUI spinner stays live
updateStatus("busy", { text: `ingesting... ${current}/${total}`, current, total });
updateStatus("busy", { text: `ingesting…`, current, total });
},
}),
timeoutMs,
Expand Down Expand Up @@ -250,7 +250,7 @@ const _serverPlugin = async (input: PluginInput) => {
project: toolCtx.directory,
progressCallback: ({ current, total }) => {
// Update status file every tick so TUI spinner stays live
updateStatus("busy", { text: `ingesting... ${current}/${total}`, current, total });
updateStatus("busy", { text: `ingesting…`, current, total });
},
}),
timeoutMs,
Expand Down Expand Up @@ -295,7 +295,7 @@ const _serverPlugin = async (input: PluginInput) => {
project: s.string().optional().describe("Project name or hash to scope search"),
},
execute: async (args, toolCtx) => {
updateStatus("busy", { text: "searching..." });
updateStatus("busy", { text: "searching…" });
const db = initBrainDatabase();
try {
const results = await withTimeout(
Expand Down Expand Up @@ -691,6 +691,7 @@ const _serverPlugin = async (input: PluginInput) => {
output.system.push(brainSystemPrompt());
},
"chat.message": async (_hookInput, output) => {
if (_hookInput?.sessionID) setSessionId(_hookInput.sessionID);

@cubic-dev-ai cubic-dev-ai Bot Jun 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Global status channel is overwritten per latest chat message, so concurrent sessions can receive each other’s status updates. This causes incorrect progress/status display across sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/four-opencode-brain.ts, line 694:

<comment>Global status channel is overwritten per latest chat message, so concurrent sessions can receive each other’s status updates. This causes incorrect progress/status display across sessions.</comment>

<file context>
@@ -691,6 +691,7 @@ const _serverPlugin = async (input: PluginInput) => {
       output.system.push(brainSystemPrompt());
     },
     "chat.message": async (_hookInput, output) => {
+      if (_hookInput?.sessionID) setSessionId(_hookInput.sessionID);
       if (output.message?.role === "user" && output.message?.content) {
         const stored = await onChatMessage(input, output.message as { role: string; content: string });
</file context>
Fix with cubic

if (output.message?.role === "user" && output.message?.content) {
const stored = await onChatMessage(input, output.message as { role: string; content: string });
if (stored && output.parts) {
Expand Down
33 changes: 30 additions & 3 deletions src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ 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 { BusClient } from "@four-bytes/opencode-plugin-lib";
import type { BrainStatusEvent } from "./event-bus";

export type StatusState = "busy" | "success" | "warning" | "error" | "ready";

Expand All @@ -23,22 +24,41 @@ export interface StatusOpts {
const _state = { current: {} as Record<string, unknown> };
_state.current = { status: "init", statusText: "", version: "" };
let _version = "";
let _sessionId = "";
let _channel = "brain/status";

let _client: PluginInput["client"] | null = null;
let _server: ReturnType<typeof Bun.serve> | null = null;
let _port = 0;
let _busPromise: Promise<BusClient> | null = null;

/** Initialize with client for toast support */
export function initVersion(v: string): void {
_version = v;
write({ status: "init", statusText: "initializing..." });
write({ status: "init", statusText: "initializing…" });
}

export function setSessionId(id: string): void {
if (id === _sessionId) return;
_sessionId = id;
_channel = `brain/${id}`;
}

export function initStatus(client: PluginInput["client"], directory: string): void {
_client = client;
startStatusServer(directory);
}

function getBus(): Promise<BusClient> {
if (!_busPromise) {
_busPromise = BusClient.connect().catch((err) => {
console.warn("[brain] BusClient connect failed:", (err as Error).message);
throw err;
});
Comment on lines +54 to +57

@cubic-dev-ai cubic-dev-ai Bot Jun 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: getBus permanently caches a rejected BusClient connection. One transient connect failure disables all future bus publishes until process restart.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/status.ts, line 54:

<comment>getBus permanently caches a rejected BusClient connection. One transient connect failure disables all future bus publishes until process restart.</comment>

<file context>
@@ -23,22 +24,41 @@ export interface StatusOpts {
 
+function getBus(): Promise<BusClient> {
+  if (!_busPromise) {
+    _busPromise = BusClient.connect().catch((err) => {
+      console.warn("[brain] BusClient connect failed:", (err as Error).message);
+      throw err;
</file context>
Suggested change
_busPromise = BusClient.connect().catch((err) => {
console.warn("[brain] BusClient connect failed:", (err as Error).message);
throw err;
});
_busPromise = BusClient.connect().catch((err) => {
_busPromise = null;
console.warn("[brain] BusClient connect failed:", (err as Error).message);
throw err;
});
Fix with cubic

}
return _busPromise;
}

export function startStatusServer(directory: string): void {
if (_server) return;

Expand Down Expand Up @@ -84,7 +104,14 @@ export function stopStatusServer(): void {

function write(data: Record<string, unknown>): void {
_state.current = { ..._state.current, ...data };
brainBus.emit("status", { ..._state.current, version: _version } as BrainStatusEvent);
const payload = { ..._state.current, version: _version } as BrainStatusEvent;

// Real-time push via plugin bus (HTTP fallback still serves status endpoint)
getBus()
.then((bus) => bus.publish(_channel, payload))
.catch((err) => {
console.warn("[brain] Bus publish failed:", (err as Error).message);
});
}

/**
Expand Down
62 changes: 50 additions & 12 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
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 { 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";
import { createHash } from "crypto";
import { homedir } from "os";
import { join } from "path";

function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi; sessionId?: string }) {
const [indicator, setIndicator] = createSignal("•");
const [status, setStatus] = createSignal("connecting...");
const [status, setStatus] = createSignal("connecting…");
const [version, setVersion] = createSignal("");
const [current, setCurrent] = createSignal(0);
const [total, setTotal] = createSignal(0);
Expand All @@ -37,15 +39,19 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
setFg(theme().error);
} else if (data.status === "init") {
setBusy(true);
setStatus(data.statusText ?? "initializing...");
setStatus(data.statusText ?? "initializing…");
setCurrent(0);
setTotal(0);
setFg(theme().warning);
} else if (data.status === "busy") {
setBusy(true);
setCurrent(data.current ?? 0);
setTotal(data.total ?? 0);
setStatus(data.statusText ?? "working");
setStatus(data.statusText ?? "working…");
setFg(pulse % 2 === 0 ? theme().warning : theme().accent);
} else {
setCurrent(0);
setTotal(0);
setBusy(false);
setIndicator("•");
setStatus("ready");
Expand All @@ -60,9 +66,30 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
};

onMount(() => {
const unsub = brainBus.on("status", handleStatus);
let bus: BusTui | null = null;
let unsub: (() => void) | null = null;
let timer: ReturnType<typeof setInterval> | null = null;

// Resolve port from discovery file, then poll HTTP endpoint
onCleanup(() => {
unsub?.();
bus?.close();
if (timer) clearInterval(timer);
});

// Real-time WebSocket subscription via plugin bus
BusTui.connect()

@cubic-dev-ai cubic-dev-ai Bot Jun 13, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Async bus subscription can leak when component unmounts before BusTui.connect() resolves. Guard with a disposed flag and close/unsubscribe immediately in the resolve path when already unmounted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tui.tsx, line 80:

<comment>Async bus subscription can leak when component unmounts before `BusTui.connect()` resolves. Guard with a disposed flag and close/unsubscribe immediately in the resolve path when already unmounted.</comment>

<file context>
@@ -60,9 +66,30 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
+    });
+
+    // Real-time WebSocket subscription via plugin bus
+    BusTui.connect()
+      .then((b) => {
+        bus = b;
</file context>
Fix with cubic

.then((b) => {
bus = b;
const channel = `brain/${props.sessionId || "unknown"}`;
unsub = b.subscribe(channel, (envelope) => {
handleStatus(envelope.payload as BrainStatusEvent);
});
})
.catch((err) => {
console.warn("[brain TUI] BusTui connect failed:", (err as Error).message);
});

// HTTP fallback for when bus is unavailable (cross-process)
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`);
Expand Down Expand Up @@ -92,15 +119,20 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
};

poll();
const timer = setInterval(poll, 200);
onCleanup(() => { unsub(); clearInterval(timer); });
timer = setInterval(poll, 200);
});

const StatusRow = () => (
<box flexDirection="row">
<text fg={theme().textMuted}>🧠 {version()} </text>
{busy() ? <Spinner fg={fg()} /> : <text fg={connecting() ? theme().error : fg()}>{indicator()}</text>}
<text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting..." : status()}</text>
<text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting…" : status()}</text>
{current() > 0 && total() > 0 && (
<text> </text>
)}
{current() > 0 && total() > 0 && (
<ProgressBar current={current()} total={total()} showLabel={true} fillBg="#aaa" fillFg="#000" />
)}
</box>
);

Expand All @@ -123,7 +155,13 @@ function BrainStatusBar(props: { centered?: boolean; api: TuiPluginApi }) {
</box>
<box flexDirection="row">
{busy() ? <Spinner fg={fg()} /> : <text fg={connecting() ? theme().error : fg()}>{indicator()}</text>}
<text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting..." : status()}</text>
<text fg={connecting() ? theme().error : theme().textMuted}> {connecting() ? "connecting…" : status()}</text>
{current() > 0 && total() > 0 && (
<text> </text>
)}
{current() > 0 && total() > 0 && (
<ProgressBar current={current()} total={total()} showLabel={true} fillBg="#aaa" fillFg="#000" />
)}
</box>
</box>
)}
Expand All @@ -137,7 +175,7 @@ const tui: TuiPlugin = (api) => {
api.slots.register({
order: 60, // below deepseek-meter (55)
slots: {
sidebar_content: () => <BrainStatusBar api={api} />,
sidebar_content: (_ctx: any, props: any) => <BrainStatusBar api={api} sessionId={props.session_id} />,
home_bottom: () => <BrainStatusBar api={api} centered />,
},
});
Expand Down
Loading