From 8fa32c12c473db656208e20402222f9a5d62896e Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 18:50:03 +0200 Subject: [PATCH 1/8] fix: sidebar always subscribes to brain/status for ingest progress --- src/tui.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index 9121d1d..f05b24e 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -4,6 +4,7 @@ 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 type { Unsubscribe } 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"; @@ -62,12 +63,14 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; onMount(() => { const [bus, setBus] = createSignal(null); - let unsub: (() => void) | null = null; + let unsubMain: (() => void) | null = null; + let unsubSession: (() => void) | null = null; let unmounted = false; onCleanup(() => { unmounted = true; - unsub?.(); + unsubSession?.(); + unsubMain?.(); bus()?.close(); }); @@ -75,14 +78,17 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; .then((b) => { if (unmounted) { b.close(); return; } setBus(b); - // 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). + // Always subscribe to unscoped "status" (catches ingest before session ID is set). + // Also subscribe to session-scoped "status" when available (post-chat updates). const scoped = b.forService("brain"); - const brainBus = props.sessionId ? scoped.forSession(props.sessionId) : scoped; - unsub = brainBus.subscribe("status", (envelope) => { + unsubMain = scoped.subscribe("status", (envelope) => { handleStatus(envelope.payload as BrainStatusEvent); }); + if (props.sessionId) { + unsubSession = scoped.forSession(props.sessionId).subscribe("status", (envelope) => { + handleStatus(envelope.payload as BrainStatusEvent); + }); + } }) .catch((err) => { console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); From fef1dc8eef14dac9cec02bd586557b2d5c78d325 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 18:55:44 +0200 Subject: [PATCH 2/8] fix: forSession always on both sides --- src/tui.tsx | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index f05b24e..9ea5d5b 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -62,15 +62,21 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; }; onMount(() => { + const sessionId = props.sessionId; + if (!sessionId) { + // No session context — show "connecting…" honestly. Do NOT subscribe to the unscoped + // channel: server-side publishes are per-session, and an unscoped fallback would leak + // status from other sessions into this one. + return; + } + const [bus, setBus] = createSignal(null); - let unsubMain: (() => void) | null = null; - let unsubSession: (() => void) | null = null; + let unsub: Unsubscribe | null = null; let unmounted = false; onCleanup(() => { unmounted = true; - unsubSession?.(); - unsubMain?.(); + unsub?.(); bus()?.close(); }); @@ -78,17 +84,10 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; .then((b) => { if (unmounted) { b.close(); return; } setBus(b); - // Always subscribe to unscoped "status" (catches ingest before session ID is set). - // Also subscribe to session-scoped "status" when available (post-chat updates). - const scoped = b.forService("brain"); - unsubMain = scoped.subscribe("status", (envelope) => { + // Session-scoped subscription only — mirrors the server's forSession(sid) publish. + unsub = b.forService("brain").forSession(sessionId).subscribe("status", (envelope) => { handleStatus(envelope.payload as BrainStatusEvent); }); - if (props.sessionId) { - unsubSession = scoped.forSession(props.sessionId).subscribe("status", (envelope) => { - handleStatus(envelope.payload as BrainStatusEvent); - }); - } }) .catch((err) => { console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); @@ -146,7 +145,11 @@ const tui: TuiPlugin = (api) => { order: 60, slots: { sidebar_content: (_ctx: any, props: any) => , - home_bottom: () => , + home_bottom: (_ctx: any, _props: any) => { + const route = api.route.current; + const sid = route.name === "session" && route.params ? (route.params as { sessionID?: string }).sessionID : undefined; + return ; + }, }, }); return Promise.resolve(); From a242763ceef4008ab8d0fe48922a240ed83d4cd7 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 18:59:50 +0200 Subject: [PATCH 3/8] fix: defer auto-ingest to session.created, forSession only --- src/four-opencode-brain.ts | 78 ++++++++++++++++++++++++++++---------- src/status.ts | 9 +++-- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index b8636e5..2116b4e 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, setSessionId, stopStatusServer, toast } from "./status"; +import { updateStatus, initStatus, initVersion, setSessionId, stopStatusServer, toast, withSessionId } from "./status"; @@ -91,18 +91,25 @@ const _serverPlugin = async (input: PluginInput) => { try { hasGit = statSync(join(normDir, ".git")).isDirectory(); } catch {} const shouldSkip = !hasGit || isSystemDir; - if (autoIngest && directory && !shouldSkip) { - log("info", "auto-ingest", "Auto-ingest starting", { directory }); - - // Fire-and-forget — don't block plugin readiness - (async () => { + // Auto-ingest is deferred until session.created — we need a session ID to publish + // status updates on a scoped bus channel. The actual ingest is triggered from the + // "event" hook below. _autoIngestDone ensures it runs exactly once per plugin lifetime. + let _autoIngestDone = false; + + /** + * Run the auto-ingest inside a withSessionId(...) context so all updateStatus calls + * publish on brain/{sessionId} — matching the TUI's forSession(sessionId) subscription. + */ + const runAutoIngest = async (sessionID: string) => { + log("info", "auto-ingest", "Auto-ingest starting (deferred)", { directory, sessionID }); + return withSessionId(sessionID, async () => { // Signal TUI we're scanning the directory tree 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); + const walked = await resolveFiles(directory!, true); fileCount = walked.files.length; updateStatus("busy", { text: `scanning files… ${fileCount}`, total: fileCount }); const timeoutS = (calculateIngestTimeout(fileCount) / 1000).toFixed(0); @@ -118,10 +125,10 @@ const _serverPlugin = async (input: PluginInput) => { let lastUpdate = 0; try { const result = await withTimeout( - ingestPath(ingestDb, directory, { + ingestPath(ingestDb, directory!, { recursive: true, reIndex: false, - project: directory, + project: directory!, progressCallback: ({ current, total }) => { const now = Date.now(); if (now - lastUpdate < 500 && current !== total) return; // throttle to 0.5s (but always emit final update) @@ -133,7 +140,7 @@ const _serverPlugin = async (input: PluginInput) => { `auto-ingest ${directory}`, ); if (result.filesFound === 0) { - const dirname = directory.split("/").filter(Boolean).pop() ?? directory; + const dirname = directory!.split("/").filter(Boolean).pop() ?? directory!; const msg = `🧠 Found 0 files in ${dirname} — check path`; updateStatus("warning", { text: msg.replace("🧠 ", ""), toast: msg.replace("🧠 ", "") }); toast( msg.replace("🧠 ", ""), "warning", "Brain 🧠"); @@ -173,10 +180,10 @@ const _serverPlugin = async (input: PluginInput) => { } finally { ingestDb.close(); } - })(); - } + }); + }; - else if (autoIngest && shouldSkip) { + if (autoIngest && shouldSkip) { log("warn", "auto-ingest", "Skipped — not a git repo or system dir: " + normDir); updateStatus("warning", { text: "ingest excluded" }); } @@ -229,6 +236,7 @@ const _serverPlugin = async (input: PluginInput) => { reIndex: s.boolean().optional().describe("Force re-index even if unchanged (default: false)"), }, execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); const resolvedPath = resolve(toolCtx.directory, args.path); try { @@ -288,6 +296,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -301,6 +310,7 @@ const _serverPlugin = async (input: PluginInput) => { project: s.string().optional().describe("Project name or hash to scope search"), }, execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { updateStatus("busy", { text: "searching…" }); const db = initBrainDatabase(); try { @@ -339,13 +349,15 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); const brain_reindex = tool({ description: "Rebuild vec0 vector index from chunks.", args: {}, - execute: async () => { + execute: async (_args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { updateStatus("busy", { text: "Rebuilding vector index…" }); const db = initBrainDatabase(); try { @@ -384,6 +396,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -407,7 +420,8 @@ const _serverPlugin = async (input: PluginInput) => { diaryContent: s.string().optional().describe("Diary entry content (for add)"), diaryDate: s.string().optional().describe("Diary date YYYY-MM-DD (defaults today)"), }, - execute: async (args) => { + execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); try { switch (args.mode) { @@ -481,6 +495,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -519,7 +534,8 @@ const _serverPlugin = async (input: PluginInput) => { confidence: s.number().optional(), review_state: s.string().optional(), }, - execute: async (args) => { + execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); try { updateStatus("busy", { text: "Saving knowledge entry…" }); @@ -545,6 +561,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -560,7 +577,8 @@ const _serverPlugin = async (input: PluginInput) => { commit_ref: s.string().optional(), observed_symptoms: s.string().optional(), }, - execute: async (args) => { + execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); try { updateStatus("busy", { text: "Recording occurrence…" }); @@ -584,6 +602,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -595,7 +614,8 @@ const _serverPlugin = async (input: PluginInput) => { review_state: s.string().describe("draft|reviewed|accepted|rejected|superseded"), confidence: s.number().optional(), }, - execute: async (args) => { + execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); try { updateStatus("busy", { text: "Updating review…" }); @@ -615,6 +635,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -629,7 +650,8 @@ const _serverPlugin = async (input: PluginInput) => { limit: s.number().optional().describe("Max results (default 20)"), offset: s.number().optional().describe("Result offset"), }, - execute: async (args) => { + execute: async (args, toolCtx) => { + return withSessionId(toolCtx.sessionID, async () => { const db = initBrainDatabase(); try { const results = kbSearch(db, { @@ -650,6 +672,7 @@ const _serverPlugin = async (input: PluginInput) => { } finally { db.close(); } + }); // withSessionId }, }); @@ -713,6 +736,23 @@ const _serverPlugin = async (input: PluginInput) => { } }, "event": async (eventInput) => { + // Capture session ID as soon as a session exists — this enables forSession(sid) + // publishes from updateStatus(). Also triggers deferred auto-ingest exactly once, + // wrapped in withSessionId(sid) so its status updates land on the right channel. + if (eventInput.event.type === "session.created") { + const { sessionID } = eventInput.event.properties as { sessionID?: string }; + if (sessionID) { + setSessionId(sessionID); + if (!_autoIngestDone && autoIngest && directory && !shouldSkip) { + _autoIngestDone = true; + // Fire-and-forget — don't block the event hook + runAutoIngest(sessionID).catch((err) => { + log("error", "auto-ingest", `Deferred auto-ingest failed: ${String(err)}`); + }); + } + } + return; + } if (eventInput.event.type === "session.idle") { const { sessionID } = eventInput.event.properties; let text = ""; diff --git a/src/status.ts b/src/status.ts index 8e26e11..799e10e 100644 --- a/src/status.ts +++ b/src/status.ts @@ -130,15 +130,18 @@ export function stopStatusServer(): void { function write(data: Record): void { _state.current = { ..._state.current, ...data }; // 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. + // If no session ID is available, skip the bus publish entirely — the TUI only + // subscribes on forSession(sid), so there is no unscoped consumer to receive on. + // The HTTP /status endpoint still serves polling clients. const sid = _sessionAls.getStore() ?? ""; const payload = { ..._state.current, version: _version, sessionId: sid || undefined } as BrainStatusEvent; + if (!sid) return; + // Real-time push via scoped plugin bus (HTTP fallback still serves status endpoint) getBus() .then(async (bus) => { - const scoped = bus.forService("brain"); - const target = sid ? scoped.forSession(sid) : scoped; + const target = bus.forService("brain").forSession(sid); await target.publish("status", payload); }) .catch((err) => { From 872092e215d780e4b00421af0b9e30d12e496205 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 19:14:28 +0200 Subject: [PATCH 4/8] feat: expose brain-cli binary in package.json bin field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables `bun link` to install brain-cli globally (~/.bun/bin/brain-cli). Makes the brain accessible from Claude Code, Cursor, and any AI tool via Bash — not just opencode's native plugin tool calls. --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index f406c96..eee51e1 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "devDependencies": { "bun-types": "1.3.14" }, + "bin": { + "brain-cli": "src/cli.ts" + }, "exports": { "./server": { "types": "./dist/four-opencode-brain.d.ts", From bb38b8fdc785d27f44367f53db019a6ed0a7417f Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 19:22:10 +0200 Subject: [PATCH 5/8] fix: replace onMount+early-return with createEffect for reactive session subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onMount fires once — if sessionId is undefined at mount time (sidebar exists before any session), the early return prevents the subscription from ever being set up. When the session is later created, props.sessionId changes reactively but onMount does not re-run, so the TUI never receives bus events. Fix: split into two parts: - onMount: establishes the BusTui connection exactly once per component instance - createEffect: reactively subscribes/resubscribes whenever busTui() or props.sessionId changes; onCleanup inside the effect tears down stale subscriptions before each re-run This ensures the sidebar status bar connects correctly even when it mounts before a session exists (the typical case). --- src/tui.tsx | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/tui.tsx b/src/tui.tsx index 9ea5d5b..521eb16 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, onMount, onCleanup, Show } from "solid-js"; +import { createSignal, createEffect, 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"; @@ -61,39 +61,44 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; } }; - onMount(() => { - const sessionId = props.sessionId; - if (!sessionId) { - // No session context — show "connecting…" honestly. Do NOT subscribe to the unscoped - // channel: server-side publishes are per-session, and an unscoped fallback would leak - // status from other sessions into this one. - return; - } - - const [bus, setBus] = createSignal(null); - let unsub: Unsubscribe | null = null; - let unmounted = false; + // Single bus connection per component instance — established once on mount. + const [busTui, setBusTui] = createSignal(null); + onMount(() => { + let disposed = false; onCleanup(() => { - unmounted = true; - unsub?.(); - bus()?.close(); + disposed = true; + busTui()?.close(); + setBusTui(null); }); BusTui.connect() .then((b) => { - if (unmounted) { b.close(); return; } - setBus(b); - // Session-scoped subscription only — mirrors the server's forSession(sid) publish. - unsub = b.forService("brain").forSession(sessionId).subscribe("status", (envelope) => { - handleStatus(envelope.payload as BrainStatusEvent); - }); + if (disposed) { b.close(); return; } + setBusTui(b); }) .catch((err) => { console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); }); }); + // Reactive subscription — re-runs whenever bus connects OR sessionId changes. + // onCleanup inside createEffect fires before each re-run and on component unmount, + // so stale subscriptions are always torn down before the new one is created. + createEffect(() => { + const b = busTui(); + const sessionId = props.sessionId; + // Do NOT subscribe without a session ID — server only publishes per-session, + // and an unscoped subscription would leak status across sessions. + if (!b || !sessionId) return; + + const unsub: Unsubscribe = b.forService("brain").forSession(sessionId).subscribe("status", (envelope) => { + handleStatus(envelope.payload as BrainStatusEvent); + }); + + onCleanup(unsub); + }); + const indicatorColor = () => connecting() ? theme().error : (hasError() ? theme().error : fg()); const textColor = () => connecting() ? theme().error : theme().textMuted; From c8d687bd44c85081865f259c634b1e0c4d303fc9 Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 19:32:25 +0200 Subject: [PATCH 6/8] refactor: use useServiceBus hook from plugin-lib --- bun.lock | 4 ++-- package.json | 2 +- src/tui.tsx | 45 ++++++--------------------------------------- 3 files changed, 9 insertions(+), 42 deletions(-) diff --git a/bun.lock b/bun.lock index 125cbed..649714e 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.6.0", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.6.1", "@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#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=="], + "@four-bytes/opencode-plugin-lib": ["@four-bytes/opencode-plugin-lib@github:four-bytes/four-opencode-plugin-lib#6fcf394", { "peerDependencies": { "@opencode-ai/plugin": ">=1.16.0", "@opentui/solid": "^0.4.1", "solid-js": "^1.9.13" } }, "four-bytes-four-opencode-plugin-lib-6fcf394", "sha512-KiGhgfknOS6HfrXbQV95Z7IhMeBvN3pwRPqmXj6+XP5Rfl0cCZyLseA2AVLZ+mqI0Z2NtNvEEoFyR9HenogHTg=="], "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], diff --git a/package.json b/package.json index eee51e1..1ce5cb1 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.6.0", + "@four-bytes/opencode-plugin-lib": "github:four-bytes/four-opencode-plugin-lib#v0.6.1", "@opencode-ai/plugin": "1.16.2", "@opentui/core": "0.3.2", "@opentui/solid": "0.3.2", diff --git a/src/tui.tsx b/src/tui.tsx index 521eb16..a95572d 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -1,10 +1,9 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"; +import { createSignal, 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 type { Unsubscribe } from "@four-bytes/opencode-plugin-lib/tui"; +import { useServiceBus } 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"; @@ -59,44 +58,12 @@ function BrainStatusBar(props: { variant: "sidebar" | "home"; api: TuiPluginApi; setFg(theme().error); setHasError(true); } - }; - // Single bus connection per component instance — established once on mount. - const [busTui, setBusTui] = createSignal(null); - - onMount(() => { - let disposed = false; - onCleanup(() => { - disposed = true; - busTui()?.close(); - setBusTui(null); - }); - - BusTui.connect() - .then((b) => { - if (disposed) { b.close(); return; } - setBusTui(b); - }) - .catch((err) => { - console.warn("[brain TUI] BusTui connect failed:", (err as Error).message); - }); - }); - - // Reactive subscription — re-runs whenever bus connects OR sessionId changes. - // onCleanup inside createEffect fires before each re-run and on component unmount, - // so stale subscriptions are always torn down before the new one is created. - createEffect(() => { - const b = busTui(); - const sessionId = props.sessionId; - // Do NOT subscribe without a session ID — server only publishes per-session, - // and an unscoped subscription would leak status across sessions. - if (!b || !sessionId) return; - - const unsub: Unsubscribe = b.forService("brain").forSession(sessionId).subscribe("status", (envelope) => { - handleStatus(envelope.payload as BrainStatusEvent); - }); +}; - onCleanup(unsub); + // Reactive bus subscription — re-subscribes on session change, cleans up on unmount. + useServiceBus("brain", () => props.sessionId, "status", (payload) => { + handleStatus(payload as BrainStatusEvent); }); const indicatorColor = () => connecting() ? theme().error : (hasError() ? theme().error : fg()); From b6ac94da69bbcbffcd58d178f8dad6433b685b2c Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 19:37:31 +0200 Subject: [PATCH 7/8] docs: mark resolved issues as fixed in ISSUES.md --- ISSUES.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 ISSUES.md diff --git a/ISSUES.md b/ISSUES.md new file mode 100644 index 0000000..eabfa89 --- /dev/null +++ b/ISSUES.md @@ -0,0 +1,53 @@ +**Status:** Last reviewed 2026-06-14. 2/2 fixed (brain), 3/3 fixed (plugin-lib), 2/2 fixed (local-bus), 2/2 fixed (context-curator). + +# Known Issues + +## #1 — Bus reconnect never triggered after initial connection + +**Symptom:** Once brain's `_busPromise` resolves successfully, a subsequent bus death (idle +shutdown, crash) causes all `write()` calls to fail silently. No reconnect is ever attempted +for the lifetime of the plugin process. The TUI stops receiving status updates; the bus +never restarts from the brain side. + +**Root cause:** `getBus()` in `status.ts` caches `_busPromise` and only nulls it on +`BusClient.connect()` rejection. `BusClient.connect()` never rejects (falls back to +`MemoryBusClient`), so `_busPromise` is never null after the first call. + +When the bus dies post-connect, `write()` swallows the publish error in `.catch()` but does +NOT reset `_busPromise`. Every subsequent `write()` calls the same dead `BusClient`. + +**Location:** `src/status.ts` — `write()` (line ~139) and `getBus()` (line ~75). + +**Fix:** Reset `_busPromise = null` when a publish fails, so the next `write()` triggers +`BusClient.connect()` which re-discovers the new bus (or spawns one): + +```typescript +getBus() + .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); + _busPromise = null; // reset — next write() will reconnect + }); +``` + +**Dependency:** This fix is only fully effective once `@four-bytes/opencode-plugin-lib` has +the spawn lock (plugin-lib ISSUES #2) to prevent simultaneous re-spawn races. + +--- + +✅ FIXED — commit cb15dc8 (reset `_busPromise = null` in write catch) + +## #2 — ALS-based session scoping is a footgun (future work) + +`withSessionId` / `AsyncLocalStorage` wraps every tool execute to scope status publishes to +the right channel. This is fragile: any await that crosses an async boundary without the ALS +context will silently publish to the wrong (or no) session channel. + +The ROADMAP (Wave 2, Task 2.3) replaces this with an explicit `createSessionStatus(sessionId)` +that holds a `SessionPublisher`. No ALS needed; session ID is passed explicitly at call sites. + +⚠️ ROADMAP Wave 2 — not yet fixed. From 77dbb88b029e90fba33c73cce8d5022c6e2b143a Mon Sep 17 00:00:00 2001 From: 4 Bytes Robby Date: Sun, 14 Jun 2026 19:59:14 +0200 Subject: [PATCH 8/8] fix: re-publish state on setSessionId for continue mode --- src/four-opencode-brain.ts | 1 + src/status.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/four-opencode-brain.ts b/src/four-opencode-brain.ts index 2116b4e..38e204e 100644 --- a/src/four-opencode-brain.ts +++ b/src/four-opencode-brain.ts @@ -718,6 +718,7 @@ const _serverPlugin = async (input: PluginInput) => { return { "experimental.chat.system.transform": async (_hookInput, output) => { output.system.push(brainSystemPrompt()); + if (_hookInput?.sessionID) setSessionId(_hookInput.sessionID); }, "chat.message": async (_hookInput, output) => { if (_hookInput?.sessionID) setSessionId(_hookInput.sessionID); diff --git a/src/status.ts b/src/status.ts index 799e10e..3d7c8c1 100644 --- a/src/status.ts +++ b/src/status.ts @@ -65,6 +65,11 @@ export function setSessionId(id: string): void { writeFileSync(portFile, JSON.stringify({ port: _port })); } catch { /* ignore */ } } + + // Re-publish current state so TUI receives it even when session.created never fired (continue mode) + void withSessionId(id, async () => { + write({}); + }); } export function initStatus(client: PluginInput["client"], directory: string): void {