From e397212fc6866fbdf4b8ae4a38fdf59ea8e2d9d4 Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Fri, 3 Apr 2026 22:11:32 +0900 Subject: [PATCH] fix(tui): add HUD bridge polling fallback and unify state path (#1198, #1199) When fs.watch emits an error, HudFileBridge now falls back to polling via setInterval + fs.stat instead of silently ignoring the failure. Add CLAUDE_PLUGIN_DATA to HUD state dir resolution order so the TUI reads from the same directory the plugin writes to: 1. CODINGBUDDY_HUD_STATE_DIR (explicit override) 2. CLAUDE_PLUGIN_DATA (plugin convention) 3. ~/.codingbuddy (default) Closes #1198 Closes #1199 --- apps/mcp-server/src/cli/run-tui.spec.ts | 50 +++++++++++++++++++ apps/mcp-server/src/cli/run-tui.ts | 19 +++++-- .../src/tui/events/hud-file-bridge.spec.ts | 23 +++++++++ .../src/tui/events/hud-file-bridge.ts | 37 +++++++++++++- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/apps/mcp-server/src/cli/run-tui.spec.ts b/apps/mcp-server/src/cli/run-tui.spec.ts index b1bc6c90..7b21d208 100644 --- a/apps/mcp-server/src/cli/run-tui.spec.ts +++ b/apps/mcp-server/src/cli/run-tui.spec.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { IpcInstance } from '../tui/ipc/ipc.types'; // --- runTui integration tests with module mocks --- @@ -32,6 +34,54 @@ vi.mock('../tui/ipc', () => ({ }, })); +describe('resolveHudStateDir', () => { + const savedEnv: Record = {}; + + beforeEach(() => { + savedEnv.CODINGBUDDY_HUD_STATE_DIR = process.env.CODINGBUDDY_HUD_STATE_DIR; + savedEnv.CLAUDE_PLUGIN_DATA = process.env.CLAUDE_PLUGIN_DATA; + delete process.env.CODINGBUDDY_HUD_STATE_DIR; + delete process.env.CLAUDE_PLUGIN_DATA; + }); + + afterEach(() => { + if (savedEnv.CODINGBUDDY_HUD_STATE_DIR !== undefined) { + process.env.CODINGBUDDY_HUD_STATE_DIR = savedEnv.CODINGBUDDY_HUD_STATE_DIR; + } else { + delete process.env.CODINGBUDDY_HUD_STATE_DIR; + } + if (savedEnv.CLAUDE_PLUGIN_DATA !== undefined) { + process.env.CLAUDE_PLUGIN_DATA = savedEnv.CLAUDE_PLUGIN_DATA; + } else { + delete process.env.CLAUDE_PLUGIN_DATA; + } + }); + + it('returns CODINGBUDDY_HUD_STATE_DIR when set', async () => { + process.env.CODINGBUDDY_HUD_STATE_DIR = '/custom/hud-dir'; + const { resolveHudStateDir } = await import('./run-tui'); + expect(resolveHudStateDir()).toBe('/custom/hud-dir'); + }); + + it('returns CLAUDE_PLUGIN_DATA when CODINGBUDDY_HUD_STATE_DIR is unset', async () => { + process.env.CLAUDE_PLUGIN_DATA = '/plugin/data'; + const { resolveHudStateDir } = await import('./run-tui'); + expect(resolveHudStateDir()).toBe('/plugin/data'); + }); + + it('returns ~/.codingbuddy as default fallback', async () => { + const { resolveHudStateDir } = await import('./run-tui'); + expect(resolveHudStateDir()).toBe(path.join(os.homedir(), '.codingbuddy')); + }); + + it('CODINGBUDDY_HUD_STATE_DIR takes priority over CLAUDE_PLUGIN_DATA', async () => { + process.env.CODINGBUDDY_HUD_STATE_DIR = '/explicit-override'; + process.env.CLAUDE_PLUGIN_DATA = '/plugin/data'; + const { resolveHudStateDir } = await import('./run-tui'); + expect(resolveHudStateDir()).toBe('/explicit-override'); + }); +}); + describe('runTui', () => { let stderrSpy: ReturnType; let savedExitCode: typeof process.exitCode; diff --git a/apps/mcp-server/src/cli/run-tui.ts b/apps/mcp-server/src/cli/run-tui.ts index 243d31bc..615f2d7d 100644 --- a/apps/mcp-server/src/cli/run-tui.ts +++ b/apps/mcp-server/src/cli/run-tui.ts @@ -7,6 +7,20 @@ import { restartTui } from './restart-tui'; /** Forced-exit timeout for client-side shutdown (ms) */ const CLIENT_SHUTDOWN_TIMEOUT_MS = 3000; +/** + * Resolve the HUD state directory in priority order: + * 1. CODINGBUDDY_HUD_STATE_DIR (explicit override) + * 2. CLAUDE_PLUGIN_DATA (plugin convention) + * 3. ~/.codingbuddy (default) + */ +export function resolveHudStateDir(): string { + return ( + process.env.CODINGBUDDY_HUD_STATE_DIR ?? + process.env.CLAUDE_PLUGIN_DATA ?? + path.join(os.homedir(), '.codingbuddy') + ); +} + /** * Run standalone TUI client that connects to running MCP server instances. * Uses MultiSessionManager to handle multiple concurrent sessions. @@ -64,10 +78,7 @@ export async function runTui(options: { restart?: boolean } = {}): Promise const { HudFileBridge } = await import('../tui/events'); const sessions = manager.getSessions(); if (sessions.length > 0) { - const hudStatePath = path.join( - process.env.CODINGBUDDY_HUD_STATE_DIR ?? path.join(os.homedir(), '.codingbuddy'), - 'hud-state.json', - ); + const hudStatePath = path.join(resolveHudStateDir(), 'hud-state.json'); const bridge = new HudFileBridge(sessions[0].eventBus, hudStatePath); bridge.start(); hudBridge = bridge; diff --git a/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts b/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts index 23561857..366a3c4b 100644 --- a/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts +++ b/apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts @@ -99,6 +99,29 @@ describe('HudFileBridge', () => { expect(modeChanges).toHaveLength(0); }); + it('falls back to polling when fs.watch emits error', async () => { + writeHudState(hudFile, { currentMode: 'PLAN' }); + bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10, pollIntervalMs: 50 }); + bridge.start(); + + const modeChanges: Array<{ from: string | null; to: string }> = []; + eventBus.on(TUI_EVENTS.MODE_CHANGED, p => modeChanges.push(p)); + + // Simulate watcher error to trigger polling fallback + // Access internal watcher and emit error + const watcher = (bridge as unknown as { watcher: fs.FSWatcher | null }).watcher; + expect(watcher).not.toBeNull(); + watcher!.emit('error', new Error('EPERM')); + + // Write new state — polling should detect the change + await sleep(100); + writeHudState(hudFile, { currentMode: 'ACT' }); + await sleep(300); + + expect(modeChanges.length).toBeGreaterThanOrEqual(1); + expect(modeChanges[modeChanges.length - 1]).toEqual({ from: 'PLAN', to: 'ACT' }); + }); + it('handles malformed JSON gracefully', async () => { writeHudState(hudFile, { currentMode: 'PLAN' }); bridge = new HudFileBridge(eventBus, hudFile, { debounceMs: 10 }); diff --git a/apps/mcp-server/src/tui/events/hud-file-bridge.ts b/apps/mcp-server/src/tui/events/hud-file-bridge.ts index e6c8671f..86884234 100644 --- a/apps/mcp-server/src/tui/events/hud-file-bridge.ts +++ b/apps/mcp-server/src/tui/events/hud-file-bridge.ts @@ -15,6 +15,8 @@ const VALID_MODES = new Set(['PLAN', 'ACT', 'EVAL', 'AUTO']); export interface HudFileBridgeOptions { /** Debounce interval in ms (default: 150) */ debounceMs?: number; + /** Polling interval in ms when fs.watch fails (default: 1000) */ + pollIntervalMs?: number; } interface HudState { @@ -26,10 +28,12 @@ export class HudFileBridge { private readonly eventBus: TuiEventBus; private readonly filePath: string; private readonly debounceMs: number; + private readonly pollIntervalMs: number; private watcher: fs.FSWatcher | null = null; private debounceTimer: ReturnType | null = null; private pollInterval: ReturnType | null = null; + private lastMtimeMs = 0; private prev: HudState = { currentMode: null, activeAgent: null }; private stopped = false; @@ -37,6 +41,7 @@ export class HudFileBridge { this.eventBus = eventBus; this.filePath = filePath; this.debounceMs = options?.debounceMs ?? 150; + this.pollIntervalMs = options?.pollIntervalMs ?? 1000; } start(): void { @@ -56,7 +61,10 @@ export class HudFileBridge { if (!filename || filename === basename) this.scheduleProcess(); }); this.watcher.on('error', () => { - // Directory deleted or inaccessible — silently ignore + // Directory deleted or inaccessible — fall back to polling + this.watcher?.close(); + this.watcher = null; + this.startPollingFallback(); }); } catch { // Directory doesn't exist yet — poll until it appears @@ -127,6 +135,33 @@ export class HudFileBridge { } } + private startPollingFallback(): void { + if (this.stopped || this.pollInterval) return; + process.stderr.write('[codingbuddy] fs.watch failed, falling back to polling\n'); + try { + const stat = fs.statSync(this.filePath); + this.lastMtimeMs = stat.mtimeMs; + } catch { + this.lastMtimeMs = 0; + } + this.pollInterval = setInterval(() => { + if (this.stopped) { + clearInterval(this.pollInterval!); + this.pollInterval = null; + return; + } + try { + const stat = fs.statSync(this.filePath); + if (stat.mtimeMs !== this.lastMtimeMs) { + this.lastMtimeMs = stat.mtimeMs; + this.scheduleProcess(); + } + } catch { + // File may have been deleted — ignore until it reappears + } + }, this.pollIntervalMs); + } + private pollUntilExists(): void { if (this.stopped) return; this.pollInterval = setInterval(() => {