agent-ecosystem/src/preload/index.ts
Cesar Augusto Fonseca f05bf9fac4 feat: color badges for subagent types with .claude/agents/ config support
Subagent badges now show distinct colors instead of generic gray.
Colors are resolved from the project's .claude/agents/*.md frontmatter
(color field), with deterministic hash-based fallback for unconfigured types.

New AgentConfigReader service reads agent definitions via IPC, cached
per project root to avoid redundant disk reads on session refreshes.

Team member colors remain unaffected (team branch has priority).
2026-02-21 13:52:59 -03:00

470 lines
18 KiB
TypeScript

import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
import { contextBridge, ipcRenderer } from 'electron';
import {
CONTEXT_CHANGED,
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
CONTEXT_SWITCH,
HTTP_SERVER_GET_STATUS,
HTTP_SERVER_START,
HTTP_SERVER_STOP,
SSH_CONNECT,
SSH_DISCONNECT,
SSH_GET_CONFIG_HOSTS,
SSH_GET_LAST_CONNECTION,
SSH_GET_STATE,
SSH_RESOLVE_HOST,
SSH_SAVE_LAST_CONNECTION,
SSH_STATUS,
SSH_TEST,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
UPDATER_STATUS,
WINDOW_CLOSE,
WINDOW_IS_MAXIMIZED,
WINDOW_MAXIMIZE,
WINDOW_MINIMIZE,
} from './constants/ipcChannels';
import {
CONFIG_ADD_IGNORE_REGEX,
CONFIG_ADD_IGNORE_REPOSITORY,
CONFIG_ADD_TRIGGER,
CONFIG_CLEAR_SNOOZE,
CONFIG_FIND_WSL_CLAUDE_ROOTS,
CONFIG_GET,
CONFIG_GET_CLAUDE_ROOT_INFO,
CONFIG_GET_TRIGGERS,
CONFIG_HIDE_SESSION,
CONFIG_HIDE_SESSIONS,
CONFIG_OPEN_IN_EDITOR,
CONFIG_PIN_SESSION,
CONFIG_REMOVE_IGNORE_REGEX,
CONFIG_REMOVE_IGNORE_REPOSITORY,
CONFIG_REMOVE_TRIGGER,
CONFIG_SELECT_CLAUDE_ROOT_FOLDER,
CONFIG_SELECT_FOLDERS,
CONFIG_SNOOZE,
CONFIG_TEST_TRIGGER,
CONFIG_UNHIDE_SESSION,
CONFIG_UNHIDE_SESSIONS,
CONFIG_UNPIN_SESSION,
CONFIG_UPDATE,
CONFIG_UPDATE_TRIGGER,
} from './constants/ipcChannels';
import type {
AppConfig,
ClaudeRootFolderSelection,
ClaudeRootInfo,
ContextInfo,
ElectronAPI,
HttpServerStatus,
NotificationTrigger,
SessionsByIdsOptions,
SessionsPaginationOptions,
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
TriggerTestResult,
WslClaudeRootCandidate,
} from '@shared/types';
// =============================================================================
// IPC Result Types and Helpers
// =============================================================================
/**
* Standard IPC result structure returned by main process handlers.
* All config-related IPC calls return this shape.
*/
interface IpcResult<T> {
success: boolean;
data?: T;
error?: string;
}
interface IpcFileChangePayload {
type: 'add' | 'change' | 'unlink';
path: string;
projectId?: string;
sessionId?: string;
isSubagent: boolean;
}
/**
* Type-safe IPC invoker for operations that return IpcResult<T>.
* Throws an Error if the IPC call fails, otherwise returns the typed data.
*/
async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Promise<T> {
const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult<T>;
if (!result.success) {
throw new Error(result.error ?? 'Unknown error');
}
return result.data as T;
}
// Keep latest zoom factor cached even before renderer UI subscribes.
let currentZoomFactor = 1;
ipcRenderer.on(
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
(_event: Electron.IpcRendererEvent, zoomFactor: unknown) => {
if (typeof zoomFactor === 'number' && Number.isFinite(zoomFactor)) {
currentZoomFactor = zoomFactor;
}
}
);
// =============================================================================
// Electron API Implementation
// =============================================================================
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
const electronAPI: ElectronAPI = {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
getSessionsPaginated: (
projectId: string,
cursor: string | null,
limit?: number,
options?: SessionsPaginationOptions
) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options),
searchSessions: (projectId: string, query: string, maxResults?: number) =>
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
searchAllProjects: (query: string, maxResults?: number) =>
ipcRenderer.invoke('search-all-projects', query, maxResults),
getSessionDetail: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
getSessionMetrics: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-metrics', projectId, sessionId),
getWaterfallData: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) =>
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
getSessionGroups: (projectId: string, sessionId: string) =>
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>
ipcRenderer.invoke('get-sessions-by-ids', projectId, sessionIds, options),
// Repository grouping (worktree support)
getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'),
getWorktreeSessions: (worktreeId: string) =>
ipcRenderer.invoke('get-worktree-sessions', worktreeId),
// Validation methods
validatePath: (relativePath: string, projectPath: string) =>
ipcRenderer.invoke('validate-path', relativePath, projectPath),
validateMentions: (mentions: { type: 'path'; value: string }[], projectPath: string) =>
ipcRenderer.invoke('validate-mentions', mentions, projectPath),
// CLAUDE.md reading methods
readClaudeMdFiles: (projectRoot: string) =>
ipcRenderer.invoke('read-claude-md-files', projectRoot),
readDirectoryClaudeMd: (dirPath: string) =>
ipcRenderer.invoke('read-directory-claude-md', dirPath),
readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) =>
ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens),
// Agent config reading
readAgentConfigs: (projectRoot: string) =>
ipcRenderer.invoke('read-agent-configs', projectRoot),
// Notifications API
notifications: {
get: (options?: { limit?: number; offset?: number }) =>
ipcRenderer.invoke('notifications:get', options),
markRead: (id: string) => ipcRenderer.invoke('notifications:markRead', id),
markAllRead: () => ipcRenderer.invoke('notifications:markAllRead'),
delete: (id: string) => ipcRenderer.invoke('notifications:delete', id),
clear: () => ipcRenderer.invoke('notifications:clear'),
getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'),
onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => {
ipcRenderer.on(
'notification:new',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
'notification:new',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onUpdated: (
callback: (event: unknown, payload: { total: number; unreadCount: number }) => void
): (() => void) => {
ipcRenderer.on(
'notification:updated',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
'notification:updated',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onClicked: (callback: (event: unknown, data: unknown) => void): (() => void) => {
ipcRenderer.on(
'notification:clicked',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
'notification:clicked',
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
// Config API - uses typed helper to unwrap { success, data, error } responses
config: {
get: async (): Promise<AppConfig> => {
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
update: async (section: string, data: object): Promise<AppConfig> => {
return invokeIpcWithResult<AppConfig>(CONFIG_UPDATE, section, data);
},
addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REGEX, pattern);
// Re-fetch config after mutation
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REGEX, pattern);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_IGNORE_REPOSITORY, repositoryId);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_IGNORE_REPOSITORY, repositoryId);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
snooze: async (minutes: number): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_SNOOZE, minutes);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
clearSnooze: async (): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_CLEAR_SNOOZE);
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
addTrigger: async (trigger: Omit<NotificationTrigger, 'isBuiltin'>): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_ADD_TRIGGER, trigger);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
updateTrigger: async (
triggerId: string,
updates: Partial<NotificationTrigger>
): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_UPDATE_TRIGGER, triggerId, updates);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
await invokeIpcWithResult<void>(CONFIG_REMOVE_TRIGGER, triggerId);
// Return updated config
return invokeIpcWithResult<AppConfig>(CONFIG_GET);
},
getTriggers: async (): Promise<NotificationTrigger[]> => {
return invokeIpcWithResult<NotificationTrigger[]>(CONFIG_GET_TRIGGERS);
},
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
return invokeIpcWithResult<TriggerTestResult>(CONFIG_TEST_TRIGGER, trigger);
},
selectFolders: async (): Promise<string[]> => {
return invokeIpcWithResult<string[]>(CONFIG_SELECT_FOLDERS);
},
selectClaudeRootFolder: async (): Promise<ClaudeRootFolderSelection | null> => {
return invokeIpcWithResult<ClaudeRootFolderSelection | null>(
CONFIG_SELECT_CLAUDE_ROOT_FOLDER
);
},
getClaudeRootInfo: async (): Promise<ClaudeRootInfo> => {
return invokeIpcWithResult<ClaudeRootInfo>(CONFIG_GET_CLAUDE_ROOT_INFO);
},
findWslClaudeRoots: async (): Promise<WslClaudeRootCandidate[]> => {
return invokeIpcWithResult<WslClaudeRootCandidate[]>(CONFIG_FIND_WSL_CLAUDE_ROOTS);
},
openInEditor: async (): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_OPEN_IN_EDITOR);
},
pinSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_PIN_SESSION, projectId, sessionId);
},
unpinSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNPIN_SESSION, projectId, sessionId);
},
hideSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSION, projectId, sessionId);
},
unhideSession: async (projectId: string, sessionId: string): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSION, projectId, sessionId);
},
hideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSIONS, projectId, sessionIds);
},
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
},
},
// Deep link navigation
session: {
scrollToLine: (sessionId: string, lineNumber: number) =>
ipcRenderer.invoke('session:scrollToLine', sessionId, lineNumber),
},
// Zoom factor sync (used for traffic-light-safe layout)
getZoomFactor: async (): Promise<number> => currentZoomFactor,
onZoomFactorChanged: (callback: (zoomFactor: number) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, zoomFactor: unknown): void => {
if (typeof zoomFactor !== 'number' || !Number.isFinite(zoomFactor)) return;
currentZoomFactor = zoomFactor;
callback(zoomFactor);
};
ipcRenderer.on(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener);
return (): void => {
ipcRenderer.removeListener(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener);
};
},
// File change events (real-time updates)
onFileChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
callback(data);
ipcRenderer.on('file-change', listener);
return (): void => {
ipcRenderer.removeListener('file-change', listener);
};
},
// Shell operations
openPath: (targetPath: string, projectRoot?: string) =>
ipcRenderer.invoke('shell:openPath', targetPath, projectRoot),
openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url),
// Window controls (when title bar is hidden, e.g. Windows / Linux)
windowControls: {
minimize: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE),
close: () => ipcRenderer.invoke(WINDOW_CLOSE),
isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise<boolean>,
},
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void =>
callback(data);
ipcRenderer.on('todo-change', listener);
return (): void => {
ipcRenderer.removeListener('todo-change', listener);
};
},
// Updater API
updater: {
check: () => ipcRenderer.invoke(UPDATER_CHECK),
download: () => ipcRenderer.invoke(UPDATER_DOWNLOAD),
install: () => ipcRenderer.invoke(UPDATER_INSTALL),
onStatus: (callback: (event: unknown, status: unknown) => void): (() => void) => {
ipcRenderer.on(
UPDATER_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
UPDATER_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
// SSH API
ssh: {
connect: async (config: SshConnectionConfig): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_CONNECT, config);
},
disconnect: async (): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_DISCONNECT);
},
getState: async (): Promise<SshConnectionStatus> => {
return invokeIpcWithResult<SshConnectionStatus>(SSH_GET_STATE);
},
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
},
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
},
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
},
saveLastConnection: async (config: SshLastConnection): Promise<void> => {
return invokeIpcWithResult<void>(SSH_SAVE_LAST_CONNECTION, config);
},
getLastConnection: async (): Promise<SshLastConnection | null> => {
return invokeIpcWithResult<SshLastConnection | null>(SSH_GET_LAST_CONNECTION);
},
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
ipcRenderer.on(
SSH_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
SSH_STATUS,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
// Context API
context: {
list: async (): Promise<ContextInfo[]> => {
return invokeIpcWithResult<ContextInfo[]>(CONTEXT_LIST);
},
getActive: async (): Promise<string> => {
return invokeIpcWithResult<string>(CONTEXT_GET_ACTIVE);
},
switch: async (contextId: string): Promise<{ contextId: string }> => {
return invokeIpcWithResult<{ contextId: string }>(CONTEXT_SWITCH, contextId);
},
onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) => {
ipcRenderer.on(
CONTEXT_CHANGED,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
CONTEXT_CHANGED,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
},
// HTTP Server API
httpServer: {
start: async (): Promise<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_START);
},
stop: async (): Promise<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_STOP);
},
getStatus: async (): Promise<HttpServerStatus> => {
return invokeIpcWithResult<HttpServerStatus>(HTTP_SERVER_GET_STATUS);
},
},
};
// Use contextBridge to securely expose the API to the renderer process
contextBridge.exposeInMainWorld('electronAPI', electronAPI);