agent-ecosystem/src/main/ipc/config.ts

1230 lines
37 KiB
TypeScript

/**
* IPC Handlers for App Configuration.
*
* Handlers:
* - config:get: Get full app configuration
* - config:update: Update a specific config section
* - config:addIgnoreRegex: Add an ignore pattern for notifications
* - config:removeIgnoreRegex: Remove an ignore pattern
* - config:addIgnoreRepository: Add a repository to ignore list
* - config:removeIgnoreRepository: Remove a repository from ignore list
* - config:snooze: Set snooze duration for notifications
* - config:clearSnooze: Clear the snooze timer
* - config:addTrigger: Add a new notification trigger
* - config:updateTrigger: Update an existing notification trigger
* - config:removeTrigger: Remove a notification trigger
* - config:getTriggers: Get all notification triggers
* - config:testTrigger: Test a trigger against historical session data
*/
import { syncTelemetryFlag } from '@main/sentry';
import { quoteWindowsCmdArg } from '@main/utils/childProcess';
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { execFile, execFileSync, spawn } from 'child_process';
import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import {
type AppConfig,
ConfigManager,
type NotificationTrigger,
type TriggerContentType,
type TriggerMatchField,
type TriggerMode,
type TriggerTokenType,
} from '../services';
import { validateConfigUpdatePayload } from './configValidation';
import { validateTriggerId } from './guards';
import type { TriggerColor } from '@shared/constants/triggerColors';
import type {
ClaudeRootFolderSelection,
ClaudeRootInfo,
IpcResult,
WslClaudeRootCandidate,
} from '@shared/types';
const logger = createLogger('IPC:config');
const execFileAsync = promisify(execFile);
// Get singleton instance
const configManager = ConfigManager.getInstance();
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
null;
let onAgentLanguageUpdated: ((newLangCode: string) => Promise<void> | void) | null = null;
function isPathLikeCommand(command: string): boolean {
return /[\\/]/.test(command) || /^[A-Za-z]:/.test(command);
}
function resolveWindowsEditorCommand(editor: string): string {
if (process.platform !== 'win32' || isPathLikeCommand(editor)) {
return editor;
}
try {
const whereExe = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'where.exe');
const output = execFileSync(whereExe, [editor], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
return output.trim().split(/\r?\n/)[0] || editor;
} catch {
return editor;
}
}
function needsWindowsShell(command: string): boolean {
if (process.platform !== 'win32') return false;
const extension = path.extname(command).toLowerCase();
return extension === '.cmd' || extension === '.bat';
}
function launchExternalEditor(editor: string, configPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const resolvedEditor = resolveWindowsEditorCommand(editor);
const launchOptions = {
detached: true,
stdio: 'ignore' as const,
windowsHide: true,
};
let child: ReturnType<typeof spawn>;
if (needsWindowsShell(resolvedEditor)) {
const command = [resolvedEditor, configPath].map(quoteWindowsCmdArg).join(' ');
// eslint-disable-next-line sonarjs/os-command -- Windows .cmd launchers require cmd.exe; editor path is resolved via where.exe and args are cmd-escaped.
child = spawn(command, {
...launchOptions,
shell: true,
});
} else {
child = spawn(resolvedEditor, [configPath], launchOptions);
}
let settled = false;
function settle(fn: () => void): void {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
}
const timer = setTimeout(() => {
child.unref();
settle(() => resolve());
}, 500);
child.on('error', (err) => {
settle(() => reject(err));
});
});
}
/**
* Initializes config handlers with callbacks that require app-level services.
*/
export function initializeConfigHandlers(
options: {
onClaudeRootPathUpdated?: (claudeRootPath: string | null) => Promise<void> | void;
onAgentLanguageUpdated?: (newLangCode: string) => Promise<void> | void;
} = {}
): void {
onClaudeRootPathUpdated = options.onClaudeRootPathUpdated ?? null;
onAgentLanguageUpdated = options.onAgentLanguageUpdated ?? null;
}
/**
* Registers all config-related IPC handlers.
*/
export function registerConfigHandlers(ipcMain: IpcMain): void {
// Get full configuration
ipcMain.handle('config:get', handleGetConfig);
// Update configuration section
ipcMain.handle('config:update', handleUpdateConfig);
// Ignore regex pattern handlers
ipcMain.handle('config:addIgnoreRegex', handleAddIgnoreRegex);
ipcMain.handle('config:removeIgnoreRegex', handleRemoveIgnoreRegex);
// Ignore repository handlers
ipcMain.handle('config:addIgnoreRepository', handleAddIgnoreRepository);
ipcMain.handle('config:removeIgnoreRepository', handleRemoveIgnoreRepository);
// Snooze handlers
ipcMain.handle('config:snooze', handleSnooze);
ipcMain.handle('config:clearSnooze', handleClearSnooze);
// Trigger management handlers
ipcMain.handle('config:addTrigger', handleAddTrigger);
ipcMain.handle('config:updateTrigger', handleUpdateTrigger);
ipcMain.handle('config:removeTrigger', handleRemoveTrigger);
ipcMain.handle('config:getTriggers', handleGetTriggers);
ipcMain.handle('config:testTrigger', handleTestTrigger);
// Session pin handlers
ipcMain.handle('config:pinSession', handlePinSession);
ipcMain.handle('config:unpinSession', handleUnpinSession);
// Session hide handlers
ipcMain.handle('config:hideSession', handleHideSession);
ipcMain.handle('config:unhideSession', handleUnhideSession);
ipcMain.handle('config:hideSessions', handleHideSessions);
ipcMain.handle('config:unhideSessions', handleUnhideSessions);
// Dialog handlers
ipcMain.handle('config:selectFolders', handleSelectFolders);
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
// Custom project path handlers
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
// Editor handlers
ipcMain.handle('config:openInEditor', handleOpenInEditor);
logger.info('Config handlers registered (including trigger management)');
}
// =============================================================================
// Handler Functions
// =============================================================================
/**
* Handler for 'config:get' IPC call.
* Returns the full app configuration.
*/
async function handleGetConfig(_event: IpcMainInvokeEvent): Promise<IpcResult<AppConfig>> {
try {
const config = configManager.getConfig();
return { success: true, data: config };
} catch (error) {
logger.error('Error in config:get:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:update' IPC call.
* Updates a specific section of the configuration.
* Returns the full updated config.
*/
async function handleUpdateConfig(
_event: IpcMainInvokeEvent,
section: unknown,
data: unknown
): Promise<IpcResult<AppConfig>> {
try {
const validation = validateConfigUpdatePayload(section, data);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const isClaudeRootUpdate =
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'claudeRootPath');
// Capture previous language BEFORE applying the update so we can detect real changes
const prevAgentLanguage =
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'agentLanguage')
? configManager.getConfig().general.agentLanguage
: undefined;
configManager.updateConfig(validation.section, validation.data);
// Sync Sentry opt-in when general.telemetryEnabled changes
if (
validation.section === 'general' &&
Object.prototype.hasOwnProperty.call(validation.data, 'telemetryEnabled')
) {
syncTelemetryFlag(configManager.getConfig().general.telemetryEnabled);
}
if (isClaudeRootUpdate && onClaudeRootPathUpdated) {
const nextClaudeRootPath = (validation.data as { claudeRootPath?: string | null })
.claudeRootPath;
try {
await onClaudeRootPathUpdated(nextClaudeRootPath ?? null);
} catch (callbackError) {
logger.error('Failed to apply updated Claude root path at runtime:', callbackError);
}
}
if (prevAgentLanguage !== undefined && onAgentLanguageUpdated) {
const newLangCode = (validation.data as { agentLanguage?: string }).agentLanguage;
if (newLangCode && newLangCode !== prevAgentLanguage) {
try {
await onAgentLanguageUpdated(newLangCode);
} catch (callbackError) {
logger.error('Failed to notify teams about language change:', callbackError);
}
}
}
const updatedConfig = configManager.getConfig();
return { success: true, data: updatedConfig };
} catch (error) {
logger.error('Error in config:update:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addIgnoreRegex' IPC call.
* Adds a regex pattern to the notification ignore list.
*/
async function handleAddIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<IpcResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
}
// Validate that the pattern is a valid regex
try {
new RegExp(pattern);
} catch {
return { success: false, error: 'Invalid regex pattern' };
}
configManager.addIgnoreRegex(pattern);
return { success: true };
} catch (error) {
logger.error('Error in config:addIgnoreRegex:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeIgnoreRegex' IPC call.
* Removes a regex pattern from the notification ignore list.
*/
async function handleRemoveIgnoreRegex(
_event: IpcMainInvokeEvent,
pattern: string
): Promise<IpcResult> {
try {
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
}
configManager.removeIgnoreRegex(pattern);
return { success: true };
} catch (error) {
logger.error('Error in config:removeIgnoreRegex:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addIgnoreRepository' IPC call.
* Adds a repository to the notification ignore list.
*/
async function handleAddIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<IpcResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
}
configManager.addIgnoreRepository(repositoryId);
return { success: true };
} catch (error) {
logger.error('Error in config:addIgnoreRepository:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeIgnoreRepository' IPC call.
* Removes a repository from the notification ignore list.
*/
async function handleRemoveIgnoreRepository(
_event: IpcMainInvokeEvent,
repositoryId: string
): Promise<IpcResult> {
try {
if (!repositoryId || typeof repositoryId !== 'string') {
return { success: false, error: 'Repository ID is required and must be a string' };
}
configManager.removeIgnoreRepository(repositoryId);
return { success: true };
} catch (error) {
logger.error('Error in config:removeIgnoreRepository:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:snooze' IPC call.
* Sets the snooze timer for notifications.
*/
async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise<IpcResult> {
try {
if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) {
return { success: false, error: 'Minutes must be a positive number' };
}
configManager.setSnooze(minutes);
return { success: true };
} catch (error) {
logger.error('Error in config:snooze:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:clearSnooze' IPC call.
* Clears the snooze timer.
*/
async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise<IpcResult> {
try {
configManager.clearSnooze();
return { success: true };
} catch (error) {
logger.error('Error in config:clearSnooze:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addTrigger' - Adds a new notification trigger.
*/
async function handleAddTrigger(
_event: IpcMainInvokeEvent,
trigger: {
id: string;
name: string;
enabled: boolean;
contentType: string;
mode?: TriggerMode;
requireError?: boolean;
toolName?: string;
matchField?: string;
matchPattern?: string;
ignorePatterns?: string[];
tokenThreshold?: number;
tokenType?: TriggerTokenType;
repositoryIds?: string[];
color?: string;
}
): Promise<IpcResult> {
try {
if (!trigger.id || !trigger.name || !trigger.contentType) {
return {
success: false,
error: 'Trigger must have id, name, and contentType',
};
}
configManager.addTrigger({
id: trigger.id,
name: trigger.name,
enabled: trigger.enabled,
contentType: trigger.contentType as TriggerContentType,
mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'),
requireError: trigger.requireError,
toolName: trigger.toolName,
matchField: trigger.matchField as TriggerMatchField | undefined,
matchPattern: trigger.matchPattern,
ignorePatterns: trigger.ignorePatterns,
tokenThreshold: trigger.tokenThreshold,
tokenType: trigger.tokenType,
repositoryIds: trigger.repositoryIds,
color: trigger.color as TriggerColor | undefined,
isBuiltin: false,
});
return { success: true };
} catch (error) {
logger.error('Error in config:addTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add trigger',
};
}
}
/**
* Handler for 'config:updateTrigger' - Updates an existing notification trigger.
*/
async function handleUpdateTrigger(
_event: IpcMainInvokeEvent,
triggerId: string,
updates: Partial<{
name: string;
enabled: boolean;
contentType: string;
requireError: boolean;
toolName: string;
matchField: string;
matchPattern: string;
ignorePatterns: string[];
mode: TriggerMode;
tokenThreshold: number;
tokenType: TriggerTokenType;
repositoryIds: string[];
color: string;
}>
): Promise<IpcResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
return {
success: false,
error: validatedTriggerId.error ?? 'Trigger ID is required',
};
}
configManager.updateTrigger(validatedTriggerId.value!, updates as Partial<NotificationTrigger>);
return { success: true };
} catch (error) {
logger.error('Error in config:updateTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update trigger',
};
}
}
/**
* Handler for 'config:removeTrigger' - Removes a notification trigger.
*/
async function handleRemoveTrigger(
_event: IpcMainInvokeEvent,
triggerId: string
): Promise<IpcResult> {
try {
const validatedTriggerId = validateTriggerId(triggerId);
if (!validatedTriggerId.valid) {
return {
success: false,
error: validatedTriggerId.error ?? 'Trigger ID is required',
};
}
configManager.removeTrigger(validatedTriggerId.value!);
return { success: true };
} catch (error) {
logger.error('Error in config:removeTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove trigger',
};
}
}
/**
* Handler for 'config:getTriggers' - Gets all notification triggers.
*/
async function handleGetTriggers(
_event: IpcMainInvokeEvent
): Promise<IpcResult<NotificationTrigger[]>> {
try {
const triggers = configManager.getTriggers();
return { success: true, data: triggers };
} catch (error) {
logger.error('Error in config:getTriggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get triggers',
};
}
}
/**
* Handler for 'config:testTrigger' - Tests a trigger against historical session data.
* Returns errors that would have been detected by the trigger.
*
* Safety: Results are truncated if:
* - More than 10,000 total matches found
* - More than 100 sessions scanned
* - Test runs longer than 30 seconds
*/
async function handleTestTrigger(
_event: IpcMainInvokeEvent,
trigger: NotificationTrigger
): Promise<
IpcResult<{
totalCount: number;
errors: {
id: string;
sessionId: string;
projectId: string;
message: string;
timestamp: number;
source: string;
toolUseId?: string;
subagentId?: string;
lineNumber?: number;
context: { projectName: string };
}[];
/** True if results were truncated due to safety limits */
truncated?: boolean;
}>
> {
try {
const { errorDetector } = await import('../services');
const result = await errorDetector.testTrigger(trigger, 50);
// Map the DetectedError objects to the format expected by the renderer
// Include toolUseId, subagentId, and lineNumber for deep linking to exact error location
const errors = result.errors.map((error) => ({
id: error.id,
sessionId: error.sessionId,
projectId: error.projectId,
message: error.message,
timestamp: error.timestamp,
source: error.source,
toolUseId: error.toolUseId,
subagentId: error.subagentId,
lineNumber: error.lineNumber,
context: { projectName: error.context.projectName },
}));
return {
success: true,
data: { totalCount: result.totalCount, errors, truncated: result.truncated },
};
} catch (error) {
logger.error('Error in config:testTrigger:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to test trigger',
};
}
}
/**
* Handler for 'config:pinSession' - Pins a session for a project.
*/
async function handlePinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.pinSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:pinSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:unpinSession' - Unpins a session for a project.
*/
async function handleUnpinSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.unpinSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:unpinSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:openInEditor' - Opens the config JSON file in an external editor.
* Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open.
*/
async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult> {
try {
const configPath = configManager.getConfigPath();
// Try editors in priority order
const editors: string[] = [];
if (process.env.VISUAL) editors.push(process.env.VISUAL);
if (process.env.EDITOR) editors.push(process.env.EDITOR);
editors.push('cursor', 'code', 'subl', 'zed');
for (const editor of editors) {
try {
await launchExternalEditor(editor, configPath);
return { success: true };
} catch {
// Editor not found, try next
continue;
}
}
// Fallback: open with system default
const { shell } = await import('electron');
const errorMessage = await shell.openPath(configPath);
if (errorMessage) {
return { success: false, error: errorMessage };
}
return { success: true };
} catch (error) {
logger.error('Error in config:openInEditor:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
*/
async function handleAddCustomProjectPath(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult> {
try {
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.addCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in config:addCustomProjectPath:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
*/
async function handleRemoveCustomProjectPath(
_event: IpcMainInvokeEvent,
projectPath: string
): Promise<IpcResult> {
try {
if (!projectPath || typeof projectPath !== 'string') {
return { success: false, error: 'Project path is required and must be a string' };
}
configManager.removeCustomProjectPath(projectPath);
return { success: true };
} catch (error) {
logger.error('Error in config:removeCustomProjectPath:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
* Allows users to select one or more folders for trigger project scope.
*/
async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
try {
// Get the focused window for proper dialog parenting
const focusedWindow = BrowserWindow.getFocusedWindow();
// dialog.showOpenDialog accepts either (options) or (window, options)
const dialogOptions: Electron.OpenDialogOptions = {
properties: ['openDirectory', 'multiSelections'],
title: 'Select Project Folders',
buttonLabel: 'Select',
};
const result = focusedWindow
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled) {
return { success: true, data: [] };
}
return { success: true, data: result.filePaths };
} catch (error) {
logger.error('Error in config:selectFolders:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to open folder dialog',
};
}
}
/**
* Handler for 'config:selectClaudeRootFolder' - Opens native folder picker for Claude root.
*/
async function handleSelectClaudeRootFolder(
_event: IpcMainInvokeEvent
): Promise<IpcResult<ClaudeRootFolderSelection | null>> {
try {
const focusedWindow = BrowserWindow.getFocusedWindow();
const currentRootPath = getClaudeBasePath();
const dialogOptions: Electron.OpenDialogOptions = {
properties: ['openDirectory'],
title: 'Select Claude Root Folder',
buttonLabel: 'Select Folder',
defaultPath: currentRootPath,
};
const result = focusedWindow
? await dialog.showOpenDialog(focusedWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (result.canceled || result.filePaths.length === 0) {
return { success: true, data: null };
}
const selectedPath = path.resolve(path.normalize(result.filePaths[0]));
const folderName = path.basename(selectedPath);
const projectsDir = path.join(selectedPath, 'projects');
const hasProjectsDir = (() => {
try {
return fs.existsSync(projectsDir) && fs.statSync(projectsDir).isDirectory();
} catch {
return false;
}
})();
return {
success: true,
data: {
path: selectedPath,
isClaudeDirName: folderName === '.claude',
hasProjectsDir,
},
};
} catch (error) {
logger.error('Error in config:selectClaudeRootFolder:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to open Claude root folder dialog',
};
}
}
/**
* Handler for 'config:getClaudeRootInfo' - Returns default/custom/effective local Claude root paths.
*/
async function handleGetClaudeRootInfo(
_event: IpcMainInvokeEvent
): Promise<IpcResult<ClaudeRootInfo>> {
try {
const customPath = configManager.getConfig().general.claudeRootPath;
const defaultPath = getAutoDetectedClaudeBasePath();
const resolvedPath = getClaudeBasePath();
return {
success: true,
data: {
defaultPath,
resolvedPath,
customPath,
},
};
} catch (error) {
logger.error('Error in config:getClaudeRootInfo:', error);
// Last-resort fallback to a best-effort auto-detected value.
const fallbackDefault = getAutoDetectedClaudeBasePath();
return {
success: true,
data: {
defaultPath: fallbackDefault,
resolvedPath: fallbackDefault,
customPath: null,
},
};
}
}
function normalizeWslHomePath(home: string): string | null {
const trimmed = home.trim();
if (!trimmed.startsWith('/')) {
return null;
}
let normalized = path.posix.normalize(trimmed);
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function toWslUncPath(distro: string, posixPath: string): string {
const uncSuffix = posixPath.replace(/\//g, '\\');
return `\\\\wsl.localhost\\${distro}${uncSuffix}`;
}
function getWslExecutableCandidates(): string[] {
const candidates = new Set<string>();
const windir = process.env.WINDIR;
if (windir) {
candidates.add(path.join(windir, 'System32', 'wsl.exe'));
candidates.add(path.join(windir, 'Sysnative', 'wsl.exe'));
}
candidates.add('wsl.exe');
return Array.from(candidates);
}
function looksLikeUtf16Le(buffer: Buffer): boolean {
const sampleSize = Math.min(buffer.length, 512);
if (sampleSize < 2) {
return false;
}
let pairs = 0;
let nullsAtOddIndex = 0;
for (let i = 0; i + 1 < sampleSize; i += 2) {
pairs += 1;
if (buffer[i + 1] === 0) {
nullsAtOddIndex += 1;
}
}
return pairs > 0 && nullsAtOddIndex / pairs >= 0.3;
}
function decodeWslOutput(output: string | Buffer | undefined): string {
if (typeof output === 'string') {
return output.replace(/\0/g, '');
}
if (!output || output.length === 0) {
return '';
}
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
const decoded =
hasUtf16LeBom || looksLikeUtf16Le(output)
? output.toString('utf16le')
: output.toString('utf8');
return decoded.replace(/\0/g, '');
}
async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> {
const candidates = getWslExecutableCandidates();
let lastError: unknown = null;
for (const executable of candidates) {
try {
const result = await execFileAsync(executable, args, {
timeout,
windowsHide: true,
maxBuffer: 1024 * 1024,
encoding: 'buffer',
});
return {
stdout: decodeWslOutput(result.stdout),
stderr: decodeWslOutput(result.stderr),
};
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new Error('Unable to execute wsl.exe');
}
function parseWslDistros(stdout: string): string[] {
const distros: string[] = [];
const seen = new Set<string>();
const lines = stdout.split(/\r?\n/);
for (const rawLine of lines) {
let line = rawLine.replace(/\0/g, '').trim();
if (!line) {
continue;
}
line = line.replace(/^\*\s*/, '').trim();
line = stripDefaultSuffix(line);
const lower = line.toLowerCase();
if (
lower.startsWith('windows subsystem for linux') ||
lower.includes('default version') ||
lower.startsWith('the following is a list')
) {
continue;
}
const key = line.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
distros.push(line);
}
}
return distros;
}
async function listWslDistros(): Promise<string[]> {
const commands: string[][] = [['--list', '--quiet'], ['-l', '-q'], ['-l']];
for (const command of commands) {
try {
const { stdout } = await runWsl(command, 4000);
const parsed = parseWslDistros(stdout);
if (parsed.length > 0) {
return parsed;
}
} catch {
// Try the next command variant.
}
}
return [];
}
function stripDefaultSuffix(input: string): string {
const suffix = '(default)';
if (!input.toLowerCase().endsWith(suffix)) {
return input;
}
return input.slice(0, input.length - suffix.length).trimEnd();
}
async function resolveWslHome(distro: string): Promise<string | null> {
try {
const { stdout } = await runWsl(['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], 5000);
return normalizeWslHomePath(stdout);
} catch {
return null;
}
}
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
try {
const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000);
const user = stdout.trim();
return user && !user.includes('/') && !user.includes('\\') ? user : null;
} catch {
return null;
}
}
/**
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
*/
async function handleFindWslClaudeRoots(
_event: IpcMainInvokeEvent
): Promise<IpcResult<WslClaudeRootCandidate[]>> {
try {
if (process.platform !== 'win32') {
return { success: true, data: [] };
}
const distros = await listWslDistros();
if (distros.length === 0) {
return { success: true, data: [] };
}
const candidates: WslClaudeRootCandidate[] = [];
const seen = new Set<string>();
for (const distro of distros) {
const resolvedHomePath = await resolveWslHome(distro);
// Fallback: query the default WSL user, then try Windows USERNAME
const wslUser = await resolveWslDefaultUser(distro);
const fallbackUser = wslUser || process.env.USERNAME;
const fallbackHomePath = fallbackUser
? fallbackUser === 'root'
? '/root'
: `/home/${fallbackUser}`
: null;
const normalizedHome =
normalizeWslHomePath(resolvedHomePath ?? '') ??
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
if (!normalizedHome) {
continue;
}
const claudePosixPath = path.posix.join(normalizedHome, '.claude');
const claudeUncPath = toWslUncPath(distro, claudePosixPath);
const key = claudeUncPath.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const projectsPath = path.join(claudeUncPath, 'projects');
const hasProjectsDir = (() => {
try {
return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory();
} catch {
return false;
}
})();
candidates.push({
distro,
path: claudeUncPath,
hasProjectsDir,
});
}
return { success: true, data: candidates };
} catch (error) {
logger.error('Error in config:findWslClaudeRoots:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to detect WSL Claude paths',
};
}
}
/**
* Handler for 'config:hideSession' - Hides a session for a project.
*/
async function handleHideSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.hideSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:hideSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:unhideSession' - Unhides a session for a project.
*/
async function handleUnhideSession(
_event: IpcMainInvokeEvent,
projectId: string,
sessionId: string
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!sessionId || typeof sessionId !== 'string') {
return { success: false, error: 'Session ID is required and must be a string' };
}
configManager.unhideSession(projectId, sessionId);
return { success: true };
} catch (error) {
logger.error('Error in config:unhideSession:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:hideSessions' - Bulk hide sessions for a project.
*/
async function handleHideSessions(
_event: IpcMainInvokeEvent,
projectId: string,
sessionIds: string[]
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
return { success: false, error: 'Session IDs must be an array of strings' };
}
configManager.hideSessions(projectId, sessionIds);
return { success: true };
} catch (error) {
logger.error('Error in config:hideSessions:', error);
return { success: false, error: getErrorMessage(error) };
}
}
/**
* Handler for 'config:unhideSessions' - Bulk unhide sessions for a project.
*/
async function handleUnhideSessions(
_event: IpcMainInvokeEvent,
projectId: string,
sessionIds: string[]
): Promise<IpcResult> {
try {
if (!projectId || typeof projectId !== 'string') {
return { success: false, error: 'Project ID is required and must be a string' };
}
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
return { success: false, error: 'Session IDs must be an array of strings' };
}
configManager.unhideSessions(projectId, sessionIds);
return { success: true };
} catch (error) {
logger.error('Error in config:unhideSessions:', error);
return { success: false, error: getErrorMessage(error) };
}
}
// =============================================================================
// Cleanup
// =============================================================================
/**
* Removes all config-related IPC handlers.
* Should be called when shutting down.
*/
export function removeConfigHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('config:get');
ipcMain.removeHandler('config:update');
ipcMain.removeHandler('config:addIgnoreRegex');
ipcMain.removeHandler('config:removeIgnoreRegex');
ipcMain.removeHandler('config:addIgnoreRepository');
ipcMain.removeHandler('config:removeIgnoreRepository');
ipcMain.removeHandler('config:snooze');
ipcMain.removeHandler('config:clearSnooze');
ipcMain.removeHandler('config:addTrigger');
ipcMain.removeHandler('config:updateTrigger');
ipcMain.removeHandler('config:removeTrigger');
ipcMain.removeHandler('config:getTriggers');
ipcMain.removeHandler('config:testTrigger');
ipcMain.removeHandler('config:pinSession');
ipcMain.removeHandler('config:unpinSession');
ipcMain.removeHandler('config:hideSession');
ipcMain.removeHandler('config:unhideSession');
ipcMain.removeHandler('config:hideSessions');
ipcMain.removeHandler('config:unhideSessions');
ipcMain.removeHandler('config:addCustomProjectPath');
ipcMain.removeHandler('config:removeCustomProjectPath');
ipcMain.removeHandler('config:selectFolders');
ipcMain.removeHandler('config:selectClaudeRootFolder');
ipcMain.removeHandler('config:getClaudeRootInfo');
ipcMain.removeHandler('config:findWslClaudeRoots');
ipcMain.removeHandler('config:openInEditor');
logger.info('Config handlers removed');
}