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
50 changes: 50 additions & 0 deletions apps/mcp-server/src/cli/run-tui.spec.ts
Original file line number Diff line number Diff line change
@@ -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 ---
Expand Down Expand Up @@ -32,6 +34,54 @@ vi.mock('../tui/ipc', () => ({
},
}));

describe('resolveHudStateDir', () => {
const savedEnv: Record<string, string | undefined> = {};

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<typeof vi.spyOn>;
let savedExitCode: typeof process.exitCode;
Expand Down
19 changes: 15 additions & 4 deletions apps/mcp-server/src/cli/run-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -64,10 +78,7 @@ export async function runTui(options: { restart?: boolean } = {}): Promise<void>
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;
Expand Down
23 changes: 23 additions & 0 deletions apps/mcp-server/src/tui/events/hud-file-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
37 changes: 36 additions & 1 deletion apps/mcp-server/src/tui/events/hud-file-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,17 +28,20 @@ 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<typeof setTimeout> | null = null;
private pollInterval: ReturnType<typeof setInterval> | null = null;
private lastMtimeMs = 0;
private prev: HudState = { currentMode: null, activeAgent: null };
private stopped = false;

constructor(eventBus: TuiEventBus, filePath: string, options?: HudFileBridgeOptions) {
this.eventBus = eventBus;
this.filePath = filePath;
this.debounceMs = options?.debounceMs ?? 150;
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
}

start(): void {
Expand All @@ -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
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading