feat(http): implement HTTP server and route handlers for configuration, notifications, projects, sessions, and SSH management

- Introduced an HTTP server to facilitate communication with the application.
- Added route handlers for managing application configuration, including getting and updating settings.
- Implemented notification operations with routes for retrieving, marking, and deleting notifications.
- Created project and session management routes to list projects, sessions, and their details.
- Developed SSH connection management routes for connecting, disconnecting, and retrieving SSH state and configuration.
- Enhanced the application architecture to support real-time event streaming via Server-Sent Events (SSE).

This commit significantly expands the application's capabilities by integrating an HTTP server and various management routes, improving user interaction and functionality.
This commit is contained in:
matt 2026-02-12 14:44:34 +09:00
parent bd54e973ff
commit 7fa2f96ed4
45 changed files with 3144 additions and 108 deletions

View file

@ -42,9 +42,12 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@tanstack/react-virtual": "^3.10.8",
"date-fns": "^3.6.0",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",

File diff suppressed because it is too large Load diff

394
src/main/http/config.ts Normal file
View file

@ -0,0 +1,394 @@
/**
* HTTP route handlers for App Configuration.
*
* Routes:
* - GET /api/config - Get full config
* - POST /api/config/update - Update config section
* - POST /api/config/ignore-regex - Add ignore pattern
* - DELETE /api/config/ignore-regex - Remove ignore pattern
* - POST /api/config/ignore-repository - Add ignored repository
* - DELETE /api/config/ignore-repository - Remove ignored repository
* - POST /api/config/snooze - Set snooze
* - POST /api/config/clear-snooze - Clear snooze
* - POST /api/config/triggers - Add trigger
* - PUT /api/config/triggers/:triggerId - Update trigger
* - DELETE /api/config/triggers/:triggerId - Remove trigger
* - GET /api/config/triggers - Get all triggers
* - POST /api/config/triggers/:triggerId/test - Test trigger
* - POST /api/config/pin-session - Pin session
* - POST /api/config/unpin-session - Unpin session
* - POST /api/config/select-folders - No-op in browser
* - POST /api/config/open-in-editor - No-op in browser
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { validateConfigUpdatePayload } from '../ipc/configValidation';
import { validateTriggerId } from '../ipc/guards';
import {
ConfigManager,
type NotificationTrigger,
type TriggerContentType,
type TriggerMatchField,
type TriggerMode,
type TriggerTokenType,
} from '../services';
import type { TriggerColor } from '@shared/constants/triggerColors';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:config');
interface ConfigResult<T = void> {
success: boolean;
data?: T;
error?: string;
}
export function registerConfigRoutes(app: FastifyInstance): void {
const configManager = ConfigManager.getInstance();
// Get full config
app.get('/api/config', async () => {
try {
const config = configManager.getConfig();
return { success: true, data: config };
} catch (error) {
logger.error('Error in GET /api/config:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Update config section
app.post<{ Body: { section: unknown; data: unknown } }>('/api/config/update', async (request) => {
try {
const { section, data } = request.body;
const validation = validateConfigUpdatePayload(section, data);
if (!validation.valid) {
return { success: false, error: validation.error };
}
configManager.updateConfig(validation.section, validation.data);
const updatedConfig = configManager.getConfig();
return { success: true, data: updatedConfig };
} catch (error) {
logger.error('Error in POST /api/config/update:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Add ignore regex
app.post<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => {
try {
const { pattern } = request.body;
if (!pattern || typeof pattern !== 'string') {
return { success: false, error: 'Pattern is required and must be a string' };
}
try {
new RegExp(pattern);
} catch {
return { success: false, error: 'Invalid regex pattern' };
}
configManager.addIgnoreRegex(pattern);
return { success: true };
} catch (error) {
logger.error('Error in POST /api/config/ignore-regex:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Remove ignore regex
app.delete<{ Body: { pattern: string } }>('/api/config/ignore-regex', async (request) => {
try {
const { pattern } = request.body;
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 DELETE /api/config/ignore-regex:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Add ignore repository
app.post<{ Body: { repositoryId: string } }>('/api/config/ignore-repository', async (request) => {
try {
const { repositoryId } = request.body;
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 POST /api/config/ignore-repository:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Remove ignore repository
app.delete<{ Body: { repositoryId: string } }>(
'/api/config/ignore-repository',
async (request) => {
try {
const { repositoryId } = request.body;
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 DELETE /api/config/ignore-repository:', error);
return { success: false, error: getErrorMessage(error) };
}
}
);
// Set snooze
app.post<{ Body: { minutes: number } }>('/api/config/snooze', async (request) => {
try {
const { minutes } = request.body;
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 POST /api/config/snooze:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Clear snooze
app.post('/api/config/clear-snooze', async () => {
try {
configManager.clearSnooze();
return { success: true };
} catch (error) {
logger.error('Error in POST /api/config/clear-snooze:', error);
return { success: false, error: getErrorMessage(error) };
}
});
// Add trigger
app.post<{
Body: {
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;
};
}>('/api/config/triggers', async (request) => {
try {
const trigger = request.body;
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 POST /api/config/triggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to add trigger',
};
}
});
// Update trigger
app.put<{
Params: { triggerId: string };
Body: 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;
}>;
}>('/api/config/triggers/:triggerId', async (request) => {
try {
const validated = validateTriggerId(request.params.triggerId);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Trigger ID is required' };
}
configManager.updateTrigger(validated.value!, request.body as Partial<NotificationTrigger>);
return { success: true };
} catch (error) {
logger.error('Error in PUT /api/config/triggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update trigger',
};
}
});
// Remove trigger
app.delete<{ Params: { triggerId: string } }>(
'/api/config/triggers/:triggerId',
async (request) => {
try {
const validated = validateTriggerId(request.params.triggerId);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Trigger ID is required' };
}
configManager.removeTrigger(validated.value!);
return { success: true };
} catch (error) {
logger.error('Error in DELETE /api/config/triggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to remove trigger',
};
}
}
);
// Get triggers
app.get('/api/config/triggers', async () => {
try {
const triggers = configManager.getTriggers();
return { success: true, data: triggers };
} catch (error) {
logger.error('Error in GET /api/config/triggers:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get triggers',
};
}
});
// Test trigger
app.post<{ Params: { triggerId: string }; Body: NotificationTrigger }>(
'/api/config/triggers/:triggerId/test',
async (request) => {
try {
const { errorDetector } = await import('../services');
const result = await errorDetector.testTrigger(request.body, 50);
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 POST /api/config/triggers/test:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to test trigger',
};
}
}
);
// Pin session
app.post<{ Body: { projectId: string; sessionId: string } }>(
'/api/config/pin-session',
async (request) => {
try {
const { projectId, sessionId } = request.body;
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 POST /api/config/pin-session:', error);
return { success: false, error: getErrorMessage(error) };
}
}
);
// Unpin session
app.post<{ Body: { projectId: string; sessionId: string } }>(
'/api/config/unpin-session',
async (request) => {
try {
const { projectId, sessionId } = request.body;
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 POST /api/config/unpin-session:', error);
return { success: false, error: getErrorMessage(error) };
}
}
);
// Select folders - no-op in browser mode
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
return { success: true, data: [] };
});
// Open in editor - no-op in browser mode
app.post('/api/config/open-in-editor', async (): Promise<ConfigResult> => {
return { success: true };
});
}

63
src/main/http/events.ts Normal file
View file

@ -0,0 +1,63 @@
/**
* SSE (Server-Sent Events) route for real-time event streaming.
*
* Routes:
* - GET /api/events: SSE stream with keep-alive pings
*/
import { createLogger } from '@shared/utils/logger';
import type { FastifyInstance, FastifyReply } from 'fastify';
const logger = createLogger('HTTP:events');
const KEEPALIVE_INTERVAL_MS = 30_000;
/** All connected SSE clients */
const clients = new Set<FastifyReply>();
/**
* Registers the SSE events endpoint.
*/
export function registerEventRoutes(app: FastifyInstance): void {
app.get('/api/events', async (request, reply) => {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
clients.add(reply);
logger.info(`SSE client connected (total: ${clients.size})`);
// Keep-alive ping
const timer = setInterval(() => {
reply.raw.write(':ping\n\n');
}, KEEPALIVE_INTERVAL_MS);
// Cleanup on disconnect
request.raw.on('close', () => {
clearInterval(timer);
clients.delete(reply);
logger.info(`SSE client disconnected (total: ${clients.size})`);
});
// Prevent Fastify from ending the response
await reply;
});
}
/**
* Broadcasts an event to all connected SSE clients.
*/
export function broadcastEvent(channel: string, data: unknown): void {
const payload = `event: ${channel}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.raw.write(payload);
} catch {
clients.delete(client);
}
}
}

65
src/main/http/index.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* HTTP Route Registration Orchestrator.
*
* Registers all domain-specific route handlers on a Fastify instance.
* Each route file mirrors the corresponding IPC handler.
*/
import { createLogger } from '@shared/utils/logger';
import { registerConfigRoutes } from './config';
import { broadcastEvent, registerEventRoutes } from './events';
import { registerNotificationRoutes } from './notifications';
import { registerProjectRoutes } from './projects';
import { registerSearchRoutes } from './search';
import { registerSessionRoutes } from './sessions';
import { registerSshRoutes } from './ssh';
import { registerSubagentRoutes } from './subagents';
import { registerUpdaterRoutes } from './updater';
import { registerUtilityRoutes } from './utility';
import { registerValidationRoutes } from './validation';
import type {
ChunkBuilder,
DataCache,
ProjectScanner,
SessionParser,
SubagentResolver,
UpdaterService,
} from '../services';
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:routes');
export interface HttpServices {
projectScanner: ProjectScanner;
sessionParser: SessionParser;
subagentResolver: SubagentResolver;
chunkBuilder: ChunkBuilder;
dataCache: DataCache;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
}
export function registerHttpRoutes(
app: FastifyInstance,
services: HttpServices,
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>
): void {
registerProjectRoutes(app, services);
registerSessionRoutes(app, services);
registerSearchRoutes(app, services);
registerSubagentRoutes(app, services);
registerNotificationRoutes(app);
registerConfigRoutes(app);
registerValidationRoutes(app);
registerUtilityRoutes(app);
registerSshRoutes(app, services.sshConnectionManager, sshModeSwitchCallback);
registerUpdaterRoutes(app, services);
registerEventRoutes(app);
logger.info('All HTTP routes registered');
}
export { broadcastEvent };

View file

@ -0,0 +1,121 @@
/**
* HTTP route handlers for Notification Operations.
*
* Routes:
* - GET /api/notifications - Get notifications (paginated)
* - POST /api/notifications/:id/read - Mark as read
* - POST /api/notifications/read-all - Mark all as read
* - DELETE /api/notifications/:id - Delete notification
* - DELETE /api/notifications - Clear all notifications
* - GET /api/notifications/unread-count - Get unread count
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { coercePageLimit, validateNotificationId } from '../ipc/guards';
import { NotificationManager } from '../services';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:notifications');
export function registerNotificationRoutes(app: FastifyInstance): void {
// Get notifications
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/api/notifications',
async (request) => {
try {
const limit = coercePageLimit(
request.query.limit ? Number(request.query.limit) : undefined,
20
);
const rawOffset = request.query.offset ? Number(request.query.offset) : 0;
const offset =
typeof rawOffset === 'number' && Number.isFinite(rawOffset) && rawOffset >= 0
? Math.floor(rawOffset)
: 0;
const manager = NotificationManager.getInstance();
const result = await manager.getNotifications({ limit, offset });
return result;
} catch (error) {
logger.error('Error in GET /api/notifications:', getErrorMessage(error));
return {
notifications: [],
total: 0,
totalCount: 0,
unreadCount: 0,
hasMore: false,
};
}
}
);
// Mark read
app.post<{ Params: { id: string } }>('/api/notifications/:id/read', async (request) => {
try {
const validated = validateNotificationId(request.params.id);
if (!validated.valid) {
logger.error(`POST notifications/:id/read rejected: ${validated.error ?? 'unknown'}`);
return false;
}
const manager = NotificationManager.getInstance();
return await manager.markRead(validated.value!);
} catch (error) {
logger.error(`Error in POST notifications/${request.params.id}/read:`, error);
return false;
}
});
// Mark all read
app.post('/api/notifications/read-all', async () => {
try {
const manager = NotificationManager.getInstance();
return await manager.markAllRead();
} catch (error) {
logger.error('Error in POST /api/notifications/read-all:', error);
return false;
}
});
// Delete notification
app.delete<{ Params: { id: string } }>('/api/notifications/:id', async (request) => {
try {
const validated = validateNotificationId(request.params.id);
if (!validated.valid) {
logger.error(`DELETE notifications/:id rejected: ${validated.error ?? 'unknown'}`);
return false;
}
const manager = NotificationManager.getInstance();
return manager.deleteNotification(validated.value!);
} catch (error) {
logger.error(`Error in DELETE notifications/${request.params.id}:`, error);
return false;
}
});
// Clear all
app.delete('/api/notifications', async () => {
try {
const manager = NotificationManager.getInstance();
return await manager.clearAll();
} catch (error) {
logger.error('Error in DELETE /api/notifications:', error);
return false;
}
});
// Unread count
app.get('/api/notifications/unread-count', async () => {
try {
const manager = NotificationManager.getInstance();
return await manager.getUnreadCount();
} catch (error) {
logger.error('Error in GET /api/notifications/unread-count:', error);
return 0;
}
});
}

55
src/main/http/projects.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* HTTP route handlers for Project Operations.
*
* Routes:
* - GET /api/projects - List all projects
* - GET /api/repository-groups - List projects grouped by git repository
* - GET /api/worktrees/:id/sessions - List sessions for a worktree
*/
import { createLogger } from '@shared/utils/logger';
import { validateProjectId } from '../ipc/guards';
import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:projects');
export function registerProjectRoutes(app: FastifyInstance, services: HttpServices): void {
app.get('/api/projects', async () => {
try {
const projects = await services.projectScanner.scan();
return projects;
} catch (error) {
logger.error('Error in GET /api/projects:', error);
return [];
}
});
app.get('/api/repository-groups', async () => {
try {
const groups = await services.projectScanner.scanWithWorktreeGrouping();
return groups;
} catch (error) {
logger.error('Error in GET /api/repository-groups:', error);
return [];
}
});
app.get<{ Params: { id: string } }>('/api/worktrees/:id/sessions', async (request) => {
try {
const validated = validateProjectId(request.params.id);
if (!validated.valid) {
logger.error(`GET /api/worktrees/:id/sessions rejected: ${validated.error ?? 'unknown'}`);
return [];
}
const sessions = await services.projectScanner.listWorktreeSessions(validated.value!);
return sessions;
} catch (error) {
logger.error(`Error in GET /api/worktrees/${request.params.id}/sessions:`, error);
return [];
}
});
}

50
src/main/http/search.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* HTTP route handlers for Search Operations.
*
* Routes:
* - GET /api/projects/:projectId/search - Search sessions in a project
*/
import { createLogger } from '@shared/utils/logger';
import { coerceSearchMaxResults, validateProjectId, validateSearchQuery } from '../ipc/guards';
import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:search');
export function registerSearchRoutes(app: FastifyInstance, services: HttpServices): void {
app.get<{
Params: { projectId: string };
Querystring: { q?: string; maxResults?: string };
}>('/api/projects/:projectId/search', async (request) => {
const query = request.query.q ?? '';
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedQuery = validateSearchQuery(query);
if (!validatedProject.valid || !validatedQuery.valid) {
logger.error(
`GET search rejected: ${validatedProject.error ?? validatedQuery.error ?? 'Invalid inputs'}`
);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
const maxResults = coerceSearchMaxResults(
request.query.maxResults ? Number(request.query.maxResults) : undefined,
50
);
const result = await services.projectScanner.searchSessions(
validatedProject.value!,
validatedQuery.value!,
maxResults
);
return result;
} catch (error) {
logger.error(`Error in GET search for ${request.params.projectId}:`, error);
return { results: [], totalMatches: 0, sessionsSearched: 0, query };
}
});
}

279
src/main/http/sessions.ts Normal file
View file

@ -0,0 +1,279 @@
/**
* HTTP route handlers for Session Operations.
*
* Routes:
* - GET /api/projects/:projectId/sessions - List sessions
* - GET /api/projects/:projectId/sessions-paginated - Paginated sessions
* - GET /api/projects/:projectId/sessions/:sessionId - Full session detail
* - GET /api/projects/:projectId/sessions/:sessionId/groups - Conversation groups
* - GET /api/projects/:projectId/sessions/:sessionId/metrics - Session metrics
* - GET /api/projects/:projectId/sessions/:sessionId/waterfall - Waterfall data
*/
import { createLogger } from '@shared/utils/logger';
import { coercePageLimit, validateProjectId, validateSessionId } from '../ipc/guards';
import { DataCache } from '../services';
import type { SessionsPaginationOptions } from '../types';
import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:sessions');
export function registerSessionRoutes(app: FastifyInstance, services: HttpServices): void {
// List sessions
app.get<{ Params: { projectId: string } }>(
'/api/projects/:projectId/sessions',
async (request) => {
try {
const validated = validateProjectId(request.params.projectId);
if (!validated.valid) {
logger.error(`GET sessions rejected: ${validated.error ?? 'unknown'}`);
return [];
}
const sessions = await services.projectScanner.listSessions(validated.value!);
return sessions;
} catch (error) {
logger.error(`Error in GET sessions for ${request.params.projectId}:`, error);
return [];
}
}
);
// Paginated sessions
app.get<{
Params: { projectId: string };
Querystring: {
cursor?: string;
limit?: string;
includeTotalCount?: string;
prefilterAll?: string;
};
}>('/api/projects/:projectId/sessions-paginated', async (request) => {
try {
const validated = validateProjectId(request.params.projectId);
if (!validated.valid) {
logger.error(`GET sessions-paginated rejected: ${validated.error ?? 'unknown'}`);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
const cursor = request.query.cursor || null;
const limit = coercePageLimit(
request.query.limit ? Number(request.query.limit) : undefined,
20
);
const options: SessionsPaginationOptions = {
includeTotalCount: request.query.includeTotalCount !== 'false',
prefilterAll: request.query.prefilterAll !== 'false',
};
const result = await services.projectScanner.listSessionsPaginated(
validated.value!,
cursor,
limit,
options
);
return result;
} catch (error) {
logger.error(`Error in GET sessions-paginated for ${request.params.projectId}:`, error);
return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 };
}
});
// Session detail
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
// Check cache first
let sessionDetail = services.dataCache.get(cacheKey);
if (sessionDetail) {
return sessionDetail;
}
// Get session metadata
const session = await services.projectScanner.getSession(safeProjectId, safeSessionId);
if (!session) {
logger.error(`Session not found: ${safeSessionId}`);
return null;
}
// Parse session messages
const parsedSession = await services.sessionParser.parseSession(
safeProjectId,
safeSessionId
);
// Resolve subagents
const subagents = await services.subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
// Build session detail with chunks
sessionDetail = services.chunkBuilder.buildSessionDetail(
session,
parsedSession.messages,
subagents
);
// Cache the result
services.dataCache.set(cacheKey, sessionDetail);
return sessionDetail;
} catch (error) {
logger.error(
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
error
);
return null;
}
}
);
// Conversation groups
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/groups',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
logger.error(
`GET session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
);
return [];
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const parsedSession = await services.sessionParser.parseSession(
safeProjectId,
safeSessionId
);
const subagents = await services.subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
const groups = services.chunkBuilder.buildGroups(parsedSession.messages, subagents);
return groups;
} catch (error) {
logger.error(
`Error in GET session-groups for ${request.params.projectId}/${request.params.sessionId}:`,
error
);
return [];
}
}
);
// Session metrics
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/metrics',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
// Try cache first
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
const cached = services.dataCache.get(cacheKey);
if (cached) {
return cached.metrics;
}
const parsedSession = await services.sessionParser.parseSession(
safeProjectId,
safeSessionId
);
return parsedSession.metrics;
} catch (error) {
logger.error(
`Error in GET session-metrics for ${request.params.projectId}/${request.params.sessionId}:`,
error
);
return null;
}
}
);
// Waterfall data
app.get<{ Params: { projectId: string; sessionId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/waterfall',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
if (!validatedProject.valid || !validatedSession.valid) {
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
// Try cache first for session detail
let detail = services.dataCache.get(cacheKey);
if (!detail) {
const session = await services.projectScanner.getSession(safeProjectId, safeSessionId);
if (!session) return null;
const parsedSession = await services.sessionParser.parseSession(
safeProjectId,
safeSessionId
);
const subagents = await services.subagentResolver.resolveSubagents(
safeProjectId,
safeSessionId,
parsedSession.taskCalls,
parsedSession.messages
);
detail = services.chunkBuilder.buildSessionDetail(
session,
parsedSession.messages,
subagents
);
services.dataCache.set(cacheKey, detail);
}
return services.chunkBuilder.buildWaterfallData(detail.chunks, detail.processes);
} catch (error) {
logger.error(
`Error in GET waterfall for ${request.params.projectId}/${request.params.sessionId}:`,
error
);
return null;
}
}
);
}

133
src/main/http/ssh.ts Normal file
View file

@ -0,0 +1,133 @@
/**
* HTTP route handlers for SSH Connection Management.
*
* Routes:
* - POST /api/ssh/connect - Connect to SSH host
* - POST /api/ssh/disconnect - Disconnect SSH
* - GET /api/ssh/state - Get connection state
* - POST /api/ssh/test - Test connection
* - GET /api/ssh/config-hosts - Get SSH config hosts
* - POST /api/ssh/resolve-host - Resolve host config
* - POST /api/ssh/save-last-connection - Save last connection
* - GET /api/ssh/last-connection - Get last connection
*/
import { createLogger } from '@shared/utils/logger';
import { ConfigManager } from '../services';
import type {
SshConnectionConfig,
SshConnectionManager,
} from '../services/infrastructure/SshConnectionManager';
import type { SshLastConnection } from '@shared/types';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:ssh');
export function registerSshRoutes(
app: FastifyInstance,
connectionManager: SshConnectionManager,
modeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>
): void {
const configManager = ConfigManager.getInstance();
// Connect
app.post<{ Body: SshConnectionConfig }>('/api/ssh/connect', async (request) => {
try {
await connectionManager.connect(request.body);
await modeSwitchCallback('ssh');
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH connect failed:', message);
return { success: false, error: message };
}
});
// Disconnect
app.post('/api/ssh/disconnect', async () => {
try {
connectionManager.disconnect();
await modeSwitchCallback('local');
return { success: true, data: connectionManager.getStatus() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('SSH disconnect failed:', message);
return { success: false, error: message };
}
});
// Get state
app.get('/api/ssh/state', async () => {
return connectionManager.getStatus();
});
// Test connection
app.post<{ Body: SshConnectionConfig }>('/api/ssh/test', async (request) => {
try {
const result = await connectionManager.testConnection(request.body);
return { success: true, data: result };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
}
});
// Get config hosts
app.get('/api/ssh/config-hosts', async () => {
try {
const hosts = await connectionManager.getConfigHosts();
return { success: true, data: hosts };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to get SSH config hosts:', message);
return { success: true, data: [] };
}
});
// Resolve host
app.post<{ Body: { alias: string } }>('/api/ssh/resolve-host', async (request) => {
try {
const entry = await connectionManager.resolveHostConfig(request.body.alias);
return { success: true, data: entry };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error(`Failed to resolve SSH host "${request.body.alias}":`, message);
return { success: true, data: null };
}
});
// Save last connection
app.post<{ Body: SshLastConnection }>('/api/ssh/save-last-connection', async (request) => {
try {
const config = request.body;
configManager.updateConfig('ssh', {
lastConnection: {
host: config.host,
port: config.port,
username: config.username,
authMethod: config.authMethod,
privateKeyPath: config.privateKeyPath,
},
});
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to save SSH connection:', message);
return { success: false, error: message };
}
});
// Get last connection
app.get('/api/ssh/last-connection', async () => {
try {
const config = configManager.getConfig();
return { success: true, data: config.ssh.lastConnection };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logger.error('Failed to get last SSH connection:', message);
return { success: true, data: null };
}
});
}

View file

@ -0,0 +1,77 @@
/**
* HTTP route handlers for Subagent Operations.
*
* Routes:
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail
*/
import { createLogger } from '@shared/utils/logger';
import { validateProjectId, validateSessionId, validateSubagentId } from '../ipc/guards';
import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:subagents');
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
const validatedSubagent = validateSubagentId(request.params.subagentId);
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
logger.error(
`GET subagent-detail rejected: ${
validatedProject.error ??
validatedSession.error ??
validatedSubagent.error ??
'Invalid parameters'
}`
);
return null;
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const safeSubagentId = validatedSubagent.value!;
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
// Check cache first
let subagentDetail = services.dataCache.getSubagent(cacheKey);
if (subagentDetail) {
return subagentDetail;
}
const fsProvider = services.projectScanner.getFileSystemProvider();
const projectsDir = services.projectScanner.getProjectsDir();
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
safeProjectId,
safeSessionId,
safeSubagentId,
services.sessionParser,
services.subagentResolver,
fsProvider,
projectsDir
);
if (!builtDetail) {
logger.error(`Subagent not found: ${safeSubagentId}`);
return null;
}
subagentDetail = builtDetail;
services.dataCache.setSubagent(cacheKey, subagentDetail);
return subagentDetail;
} catch (error) {
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
return null;
}
}
);
}

48
src/main/http/updater.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* HTTP route handlers for Update Operations.
*
* Routes:
* - POST /api/updater/check - Check for updates
* - POST /api/updater/download - Download update
* - POST /api/updater/install - Install update
*/
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:updater');
export function registerUpdaterRoutes(app: FastifyInstance, services: HttpServices): void {
app.post('/api/updater/check', async () => {
try {
await services.updaterService.checkForUpdates();
return { success: true };
} catch (error) {
logger.error('Error in POST /api/updater/check:', getErrorMessage(error));
return { success: false, error: getErrorMessage(error) };
}
});
app.post('/api/updater/download', async () => {
try {
await services.updaterService.downloadUpdate();
return { success: true };
} catch (error) {
logger.error('Error in POST /api/updater/download:', getErrorMessage(error));
return { success: false, error: getErrorMessage(error) };
}
});
app.post('/api/updater/install', async () => {
try {
services.updaterService.quitAndInstall();
return { success: true };
} catch (error) {
logger.error('Error in POST /api/updater/install:', getErrorMessage(error));
return { success: false, error: getErrorMessage(error) };
}
});
}

126
src/main/http/utility.ts Normal file
View file

@ -0,0 +1,126 @@
/**
* HTTP route handlers for Utility Operations.
*
* Routes:
* - GET /api/version - App version
* - POST /api/read-claude-md - Read CLAUDE.md files
* - POST /api/read-directory-claude-md - Read directory CLAUDE.md
* - POST /api/read-mentioned-file - Read mentioned file
* - POST /api/open-path - No-op in browser
* - POST /api/open-external - No-op in browser
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
import { validateFilePath } from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:utility');
export function registerUtilityRoutes(app: FastifyInstance): void {
// App version
app.get('/api/version', async () => {
try {
// Read version from package.json (works in both Electron and Node)
const pkgPath = path.resolve(__dirname, '../../../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string };
return pkg.version;
} catch {
return '0.0.0';
}
});
// Read CLAUDE.md files
app.post<{ Body: { projectRoot: string } }>('/api/read-claude-md', async (request) => {
try {
const { projectRoot } = request.body;
const result = await readAllClaudeMdFiles(projectRoot);
const files: Record<string, ClaudeMdFileInfo> = {};
result.files.forEach((info, key) => {
files[key] = info;
});
return files;
} catch (error) {
logger.error('Error in POST /api/read-claude-md:', error);
return {};
}
});
// Read directory CLAUDE.md
app.post<{ Body: { dirPath: string } }>('/api/read-directory-claude-md', async (request) => {
try {
const { dirPath } = request.body;
const info = await readDirectoryClaudeMd(dirPath);
return info;
} catch (error) {
logger.error('Error in POST /api/read-directory-claude-md:', error);
return {
path: request.body.dirPath,
exists: false,
charCount: 0,
estimatedTokens: 0,
};
}
});
// Read mentioned file
app.post<{ Body: { absolutePath: string; projectRoot: string; maxTokens?: number } }>(
'/api/read-mentioned-file',
async (request) => {
try {
const { absolutePath, projectRoot, maxTokens = 25000 } = request.body;
const validation = validateFilePath(absolutePath, projectRoot || null);
if (!validation.valid) {
return null;
}
const safePath = validation.normalizedPath!;
if (!fs.existsSync(safePath)) {
return null;
}
const stats = fs.statSync(safePath);
if (!stats.isFile()) {
return null;
}
const content = fs.readFileSync(safePath, 'utf8');
const estimatedTokens = countTokens(content);
if (estimatedTokens > maxTokens) {
return null;
}
return {
path: safePath,
exists: true,
charCount: content.length,
estimatedTokens,
};
} catch (error) {
logger.error(
`Error in POST /api/read-mentioned-file for ${request.body.absolutePath}:`,
error
);
return null;
}
}
);
// Open path - no-op in browser mode
app.post('/api/open-path', async () => {
return { success: false, error: 'Not available in browser mode' };
});
// Open external - no-op in browser mode
app.post<{ Body: { url: string } }>('/api/open-external', async () => {
return { success: false, error: 'Not available in browser mode' };
});
}

View file

@ -0,0 +1,98 @@
/**
* HTTP route handlers for Validation Operations.
*
* Routes:
* - POST /api/validate/path - Validate file/directory path
* - POST /api/validate/mentions - Batch validate path mentions
* - POST /api/session/scroll-to-line - Deep link scroll handler
*/
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { FastifyInstance } from 'fastify';
const logger = createLogger('HTTP:validation');
/**
* Checks if a path is contained within a base directory.
* Prevents path traversal attacks.
*/
function isPathContained(fullPath: string, basePath: string): boolean {
const normalizedFull = path.normalize(fullPath);
const normalizedBase = path.normalize(basePath);
return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep);
}
export function registerValidationRoutes(app: FastifyInstance): void {
// Validate path
app.post<{ Body: { relativePath: string; projectPath: string } }>(
'/api/validate/path',
async (request) => {
try {
const { relativePath, projectPath } = request.body;
const fullPath = path.join(projectPath, relativePath);
if (!isPathContained(fullPath, projectPath)) {
logger.warn('validate-path blocked path traversal attempt:', relativePath);
return { exists: false };
}
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stats = fs.statSync(fullPath);
return { exists: true, isDirectory: stats.isDirectory() };
} catch {
return { exists: false };
}
}
);
// Validate mentions
app.post<{ Body: { mentions: { type: 'path'; value: string }[]; projectPath: string } }>(
'/api/validate/mentions',
async (request) => {
const { mentions, projectPath } = request.body;
const results = new Map<string, boolean>();
for (const mention of mentions) {
const fullPath = path.join(projectPath, mention.value);
if (!isPathContained(fullPath, projectPath)) {
results.set(`@${mention.value}`, false);
continue;
}
results.set(`@${mention.value}`, fs.existsSync(fullPath));
}
return Object.fromEntries(results);
}
);
// Scroll to line
app.post<{ Body: { sessionId: string; lineNumber: number } }>(
'/api/session/scroll-to-line',
async (request) => {
try {
const { sessionId, lineNumber } = request.body;
if (!sessionId) {
logger.error('scroll-to-line called with empty sessionId');
return { success: false, sessionId: '', lineNumber: 0 };
}
if (typeof lineNumber !== 'number' || lineNumber < 0) {
logger.error('scroll-to-line called with invalid lineNumber');
return { success: false, sessionId, lineNumber: 0 };
}
return { success: true, sessionId, lineNumber };
} catch (error) {
logger.error('Error in POST /api/session/scroll-to-line:', error);
return { success: false, sessionId: '', lineNumber: 0 };
}
}
);
}

View file

@ -17,7 +17,7 @@ import {
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
} from '@shared/constants';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow } from 'electron';
import { app, BrowserWindow, ipcMain } from 'electron';
import { join } from 'path';
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
@ -32,8 +32,13 @@ const getIconPath = (): string => {
};
const logger = createLogger('App');
import { SSH_STATUS } from '@preload/constants/ipcChannels';
// IPC channel constants (duplicated from @preload to avoid boundary violation)
const SSH_STATUS = 'ssh:status';
const HTTP_SERVER_START = 'httpServer:start';
const HTTP_SERVER_STOP = 'httpServer:stop';
const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
import { HttpServer } from './services/infrastructure/HttpServer';
import {
configManager,
LocalFileSystemProvider,
@ -55,13 +60,14 @@ let contextRegistry: ServiceContextRegistry;
let notificationManager: NotificationManager;
let updaterService: UpdaterService;
let sshConnectionManager: SshConnectionManager;
let httpServer: HttpServer;
// File watcher event cleanup functions
let fileChangeCleanup: (() => void) | null = null;
let todoChangeCleanup: (() => void) | null = null;
/**
* Wires file watcher events from a ServiceContext to the renderer.
* Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients.
* Cleans up previous listeners before adding new ones.
*/
function wireFileWatcherEvents(context: ServiceContext): void {
@ -77,20 +83,22 @@ function wireFileWatcherEvents(context: ServiceContext): void {
todoChangeCleanup = null;
}
// Wire file-change events
// Wire file-change events to renderer and HTTP SSE
const fileChangeHandler = (event: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('file-change', event);
}
httpServer?.broadcast('file-change', event);
};
context.fileWatcher.on('file-change', fileChangeHandler);
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
// Wire todo-change events
// Wire todo-change events to renderer and HTTP SSE
const todoChangeHandler = (event: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('todo-change', event);
}
httpServer?.broadcast('todo-change', event);
};
context.fileWatcher.on('todo-change', todoChangeHandler);
todoChangeCleanup = () => context.fileWatcher.off('todo-change', todoChangeHandler);
@ -98,6 +106,17 @@ function wireFileWatcherEvents(context: ServiceContext): void {
logger.info(`FileWatcher events wired for context: ${context.id}`);
}
/**
* Handles mode switch requests from the HTTP server.
* Switches the active context back to local when requested.
*/
async function handleModeSwitch(mode: 'local' | 'ssh'): Promise<void> {
if (mode === 'local' && contextRegistry.getActiveContextId() !== 'local') {
const { current } = contextRegistry.switch('local');
onContextSwitched(current);
}
}
/**
* Callback invoked when context switches (called by SSH IPC handler).
* Re-wires file watcher events and notifies renderer.
@ -152,26 +171,116 @@ function initializeServices(): void {
// Initialize updater service
updaterService = new UpdaterService();
httpServer = new HttpServer();
// Initialize IPC handlers with registry
initializeIpcHandlers(contextRegistry, updaterService, sshConnectionManager, onContextSwitched);
// Forward SSH state changes to renderer
// HTTP Server control IPC handlers
ipcMain.handle(HTTP_SERVER_START, async () => {
try {
if (httpServer.isRunning()) {
return { success: true, data: { running: true, port: httpServer.getPort() } };
}
await startHttpServer(handleModeSwitch);
// Persist the enabled state
configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() });
return { success: true, data: { running: true, port: httpServer.getPort() } };
} catch (error) {
logger.error('Failed to start HTTP server via IPC:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to start server',
};
}
});
ipcMain.handle(HTTP_SERVER_STOP, async () => {
try {
await httpServer.stop();
// Persist the disabled state
configManager.updateConfig('httpServer', { enabled: false });
return { success: true, data: { running: false, port: httpServer.getPort() } };
} catch (error) {
logger.error('Failed to stop HTTP server via IPC:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to stop server',
};
}
});
ipcMain.handle(HTTP_SERVER_GET_STATUS, () => {
return { running: httpServer.isRunning(), port: httpServer.getPort() };
});
// Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(SSH_STATUS, status);
}
httpServer.broadcast('ssh:status', status);
});
// Forward notification events to HTTP SSE clients
notificationManager.on('notification-new', (notification: unknown) => {
httpServer.broadcast('notification:new', notification);
});
notificationManager.on('notification-updated', (data: unknown) => {
httpServer.broadcast('notification:updated', data);
});
notificationManager.on('notification-clicked', (data: unknown) => {
httpServer.broadcast('notification:clicked', data);
});
// Start HTTP server if enabled in config
const appConfig = configManager.getConfig();
if (appConfig.httpServer?.enabled) {
void startHttpServer(handleModeSwitch);
}
logger.info('Services initialized successfully');
}
/**
* Starts the HTTP sidecar server with services from the active context.
*/
async function startHttpServer(
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
): Promise<void> {
try {
const config = configManager.getConfig();
const activeContext = contextRegistry.getActive();
const port = await httpServer.start(
{
projectScanner: activeContext.projectScanner,
sessionParser: activeContext.sessionParser,
subagentResolver: activeContext.subagentResolver,
chunkBuilder: activeContext.chunkBuilder,
dataCache: activeContext.dataCache,
updaterService,
sshConnectionManager,
},
modeSwitchHandler,
config.httpServer?.port ?? 3456
);
logger.info(`HTTP sidecar server running on port ${port}`);
} catch (error) {
logger.error('Failed to start HTTP server:', error);
}
}
/**
* Shuts down all services.
*/
function shutdownServices(): void {
logger.info('Shutting down services...');
// Stop HTTP server
if (httpServer?.isRunning()) {
void httpServer.stop();
}
// Clean up file watcher event listeners
if (fileChangeCleanup) {
fileChangeCleanup();

View file

@ -7,6 +7,7 @@ import type {
AppConfig,
DisplayConfig,
GeneralConfig,
HttpServerConfig,
NotificationConfig,
NotificationTrigger,
} from '../services';
@ -28,9 +29,15 @@ export type ConfigUpdateValidationResult =
| ValidationSuccess<'notifications'>
| ValidationSuccess<'general'>
| ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'>
| ValidationFailure;
const VALID_SECTIONS = new Set<ConfigSection>(['notifications', 'general', 'display']);
const VALID_SECTIONS = new Set<ConfigSection>([
'notifications',
'general',
'display',
'httpServer',
]);
const MAX_SNOOZE_MINUTES = 24 * 60;
function isPlainObject(value: unknown): value is Record<string, unknown> {
@ -271,12 +278,58 @@ function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | V
};
}
function validateHttpServerSection(
data: unknown
): ValidationSuccess<'httpServer'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'httpServer update must be an object' };
}
const allowedKeys: (keyof HttpServerConfig)[] = ['enabled', 'port'];
const result: Partial<HttpServerConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedKeys.includes(key as keyof HttpServerConfig)) {
return { valid: false, error: `httpServer.${key} is not a valid setting` };
}
switch (key as keyof HttpServerConfig) {
case 'enabled':
if (typeof value !== 'boolean') {
return { valid: false, error: 'httpServer.enabled must be a boolean' };
}
result.enabled = value;
break;
case 'port':
if (!isFiniteNumber(value) || !Number.isInteger(value) || value < 1024 || value > 65535) {
return {
valid: false,
error: 'httpServer.port must be an integer between 1024 and 65535',
};
}
result.port = value;
break;
default:
return { valid: false, error: `Unsupported httpServer key: ${key}` };
}
}
return {
valid: true,
section: 'httpServer',
data: result,
};
}
export function validateConfigUpdatePayload(
section: unknown,
data: unknown
): ConfigUpdateValidationResult {
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return { valid: false, error: 'Section must be one of: notifications, general, display' };
return {
valid: false,
error: 'Section must be one of: notifications, general, display, httpServer',
};
}
switch (section as ConfigSection) {
@ -286,6 +339,8 @@ export function validateConfigUpdatePayload(
return validateGeneralSection(data);
case 'display':
return validateDisplaySection(data);
case 'httpServer':
return validateHttpServerSection(data);
default:
return { valid: false, error: 'Invalid section' };
}

View file

@ -204,12 +204,18 @@ export interface SshPersistConfig {
lastActiveContextId: string;
}
export interface HttpServerConfig {
enabled: boolean;
port: number;
}
export interface AppConfig {
notifications: NotificationConfig;
general: GeneralConfig;
display: DisplayConfig;
sessions: SessionsConfig;
ssh: SshPersistConfig;
httpServer: HttpServerConfig;
}
// Config section keys for type-safe updates
@ -253,6 +259,10 @@ const DEFAULT_CONFIG: AppConfig = {
profiles: [],
lastActiveContextId: 'local',
},
httpServer: {
enabled: false,
port: 3456,
},
};
// ===========================================================================
@ -377,6 +387,10 @@ export class ConfigManager {
...DEFAULT_CONFIG.ssh,
...(loaded.ssh ?? {}),
},
httpServer: {
...DEFAULT_CONFIG.httpServer,
...(loaded.httpServer ?? {}),
},
};
}

View file

@ -0,0 +1,134 @@
/**
* HttpServer - Fastify-based HTTP server for serving the renderer UI and API routes.
*
* Binds to 127.0.0.1 only for localhost security.
* Dynamically allocates a port starting from 3456.
* In production, serves static files from the renderer output directory.
* In development, Vite dev server handles static files.
*/
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { type HttpServices, registerHttpRoutes } from '@main/http';
import { broadcastEvent } from '@main/http/events';
import { createLogger } from '@shared/utils/logger';
import Fastify, { type FastifyInstance } from 'fastify';
import { join } from 'path';
const logger = createLogger('Service:HttpServer');
export class HttpServer {
private app: FastifyInstance | null = null;
private port: number = 3456;
private running: boolean = false;
/**
* Start the HTTP server.
* @param services - Service instances to pass to route handlers
* @param sshModeSwitchCallback - Callback for SSH mode switching
* @param preferredPort - Port to try first (default 3456)
*/
async start(
services: HttpServices,
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>,
preferredPort: number = 3456
): Promise<number> {
this.app = Fastify({ logger: false });
// Register CORS - allow all localhost origins
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
await this.app.register(cors, {
origin: (origin, cb) => {
// Allow requests with no origin (same-origin, curl, etc.)
if (!origin) {
cb(null, true);
return;
}
// Allow any localhost origin
if (localhostPattern.test(origin)) {
cb(null, true);
return;
}
cb(new Error('Not allowed by CORS'), false);
},
credentials: true,
});
// Register static file serving (production only)
const isDev = process.env.NODE_ENV === 'development';
if (!isDev) {
const rendererPath = join(__dirname, '../../renderer');
await this.app.register(fastifyStatic, {
root: rendererPath,
prefix: '/',
// Don't serve index.html for API routes
wildcard: false,
});
// Serve index.html for all non-API routes (SPA fallback)
this.app.setNotFoundHandler(async (request, reply) => {
if (request.url.startsWith('/api/')) {
return reply.status(404).send({ error: 'Not found' });
}
return reply.sendFile('index.html');
});
}
// Register all API routes
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
// Try ports starting from preferredPort
for (let attempt = 0; attempt <= 10; attempt++) {
const tryPort = preferredPort + attempt;
try {
await this.app.listen({ host: '127.0.0.1', port: tryPort });
this.port = tryPort;
this.running = true;
logger.info(`HTTP server started on http://127.0.0.1:${tryPort}`);
return tryPort;
} catch (err: unknown) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'EADDRINUSE') {
logger.info(`Port ${tryPort} in use, trying next...`);
continue;
}
throw err;
}
}
throw new Error(`Could not find available port (tried ${preferredPort}-${preferredPort + 10})`);
}
/**
* Stop the HTTP server gracefully.
*/
async stop(): Promise<void> {
if (this.app && this.running) {
await this.app.close();
this.running = false;
this.app = null;
logger.info('HTTP server stopped');
}
}
/**
* Broadcast an event to all connected SSE clients.
*/
broadcast(channel: string, data: unknown): void {
broadcastEvent(channel, data);
}
/**
* Get the current port the server is running on.
*/
getPort(): number {
return this.port;
}
/**
* Check if the server is currently running.
*/
isRunning(): boolean {
return this.running;
}
}

View file

@ -13,12 +13,14 @@
* - SshConnectionManager: SSH connection lifecycle
* - ServiceContext: Service bundle for a single workspace context
* - ServiceContextRegistry: Registry coordinator for all contexts
* - HttpServer: Fastify-based HTTP server for API and static file serving
*/
export * from './ConfigManager';
export * from './DataCache';
export type * from './FileSystemProvider';
export * from './FileWatcher';
export * from './HttpServer';
export * from './LocalFileSystemProvider';
export * from './NotificationManager';
export * from './ServiceContext';

View file

@ -121,3 +121,16 @@ export const CONTEXT_SWITCH = 'context:switch';
/** Context changed event channel (main -> renderer) */
export const CONTEXT_CHANGED = 'context:changed';
// =============================================================================
// HTTP Server API Channels
// =============================================================================
/** Start HTTP sidecar server */
export const HTTP_SERVER_START = 'httpServer:start';
/** Stop HTTP sidecar server */
export const HTTP_SERVER_STOP = 'httpServer:stop';
/** Get HTTP server status */
export const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';

View file

@ -6,6 +6,9 @@ import {
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,
@ -44,6 +47,7 @@ import type {
AppConfig,
ContextInfo,
ElectronAPI,
HttpServerStatus,
NotificationTrigger,
SessionsPaginationOptions,
SshConfigHostEntry,
@ -393,6 +397,19 @@ const electronAPI: ElectronAPI = {
};
},
},
// 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 ipcRenderer.invoke(HTTP_SERVER_GET_STATUS);
},
},
};
// Use contextBridge to securely expose the API to the renderer process

View file

@ -0,0 +1,535 @@
/**
* HTTP-based implementation of ElectronAPI for browser mode.
*
* Replaces Electron IPC with fetch() for request/response and
* EventSource (SSE) for real-time events. Allows the renderer
* to run in a regular browser connected to an HTTP server.
*/
import type {
AppConfig,
ClaudeMdFileInfo,
ConfigAPI,
ContextInfo,
ConversationGroup,
ElectronAPI,
FileChangeEvent,
HttpServerAPI,
HttpServerStatus,
NotificationsAPI,
NotificationTrigger,
PaginatedSessionsResult,
Project,
RepositoryGroup,
SearchSessionsResult,
Session,
SessionAPI,
SessionDetail,
SessionMetrics,
SessionsPaginationOptions,
SshAPI,
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionStatus,
SshLastConnection,
SubagentDetail,
TriggerTestResult,
UpdaterAPI,
WaterfallData,
} from '@shared/types';
export class HttpAPIClient implements ElectronAPI {
private baseUrl: string;
private eventSource: EventSource | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
constructor(port: number) {
this.baseUrl = `http://127.0.0.1:${port}`;
this.initEventSource();
}
// ---------------------------------------------------------------------------
// SSE event infrastructure
// ---------------------------------------------------------------------------
private initEventSource(): void {
this.eventSource = new EventSource(`${this.baseUrl}/api/events`);
this.eventSource.onopen = () => console.log('[HttpAPIClient] SSE connected');
this.eventSource.onerror = () => {
// Auto-reconnect is built into EventSource
console.warn('[HttpAPIClient] SSE connection error, will reconnect...');
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
private addEventListener(channel: string, callback: (...args: any[]) => void): () => void {
if (!this.eventListeners.has(channel)) {
this.eventListeners.set(channel, new Set());
// Register SSE listener for this channel once
this.eventSource?.addEventListener(channel, ((event: MessageEvent) => {
const data: unknown = JSON.parse(event.data as string);
const listeners = this.eventListeners.get(channel);
listeners?.forEach((cb) => cb(data));
}) as EventListener);
}
this.eventListeners.get(channel)!.add(callback);
return () => {
this.eventListeners.get(channel)?.delete(callback);
};
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
/**
* JSON reviver that converts ISO 8601 date strings back to Date objects.
* Electron IPC preserves Date instances via structured clone, but HTTP JSON
* serialization turns them into strings. This restores them so that
* `.getTime()` and other Date methods work in the renderer.
*/
private static readonly ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
private static reviveDates(_key: string, value: unknown): unknown {
if (typeof value === 'string' && HttpAPIClient.ISO_DATE_RE.test(value)) {
const d = new Date(value);
if (!isNaN(d.getTime())) return d;
}
return value;
}
private async parseJson<T>(res: Response): Promise<T> {
const text = await res.text();
return JSON.parse(text, HttpAPIClient.reviveDates) as T;
}
private async get<T>(path: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(`${this.baseUrl}${path}`, { signal: controller.signal });
return this.parseJson<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async post<T>(path: string, body?: unknown): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
return this.parseJson<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async del<T>(path: string, body?: unknown): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
return this.parseJson<T>(res);
} finally {
clearTimeout(timeout);
}
}
private async put<T>(path: string, body?: unknown): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
return this.parseJson<T>(res);
} finally {
clearTimeout(timeout);
}
}
// ---------------------------------------------------------------------------
// Core session/project APIs
// ---------------------------------------------------------------------------
getAppVersion = (): Promise<string> => this.get<string>('/api/version');
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
getSessions = (projectId: string): Promise<Session[]> =>
this.get<Session[]>(`/api/projects/${encodeURIComponent(projectId)}/sessions`);
getSessionsPaginated = (
projectId: string,
cursor: string | null,
limit?: number,
options?: SessionsPaginationOptions
): Promise<PaginatedSessionsResult> => {
const params = new URLSearchParams();
if (cursor) params.set('cursor', cursor);
if (limit) params.set('limit', String(limit));
if (options?.includeTotalCount === false) params.set('includeTotalCount', 'false');
if (options?.prefilterAll === false) params.set('prefilterAll', 'false');
const qs = params.toString();
const encodedId = encodeURIComponent(projectId);
const path = `/api/projects/${encodedId}/sessions-paginated`;
return this.get<PaginatedSessionsResult>(qs ? `${path}?${qs}` : path);
};
searchSessions = (
projectId: string,
query: string,
maxResults?: number
): Promise<SearchSessionsResult> => {
const params = new URLSearchParams({ q: query });
if (maxResults) params.set('maxResults', String(maxResults));
return this.get<SearchSessionsResult>(
`/api/projects/${encodeURIComponent(projectId)}/search?${params}`
);
};
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
this.get<SessionDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
);
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
this.get<SessionMetrics | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/metrics`
);
getWaterfallData = (projectId: string, sessionId: string): Promise<WaterfallData | null> =>
this.get<WaterfallData | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/waterfall`
);
getSubagentDetail = (
projectId: string,
sessionId: string,
subagentId: string
): Promise<SubagentDetail | null> =>
this.get<SubagentDetail | null>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
);
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
this.get<ConversationGroup[]>(
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/groups`
);
// ---------------------------------------------------------------------------
// Repository grouping
// ---------------------------------------------------------------------------
getRepositoryGroups = (): Promise<RepositoryGroup[]> =>
this.get<RepositoryGroup[]>('/api/repository-groups');
getWorktreeSessions = (worktreeId: string): Promise<Session[]> =>
this.get<Session[]>(`/api/worktrees/${encodeURIComponent(worktreeId)}/sessions`);
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
validatePath = (
relativePath: string,
projectPath: string
): Promise<{ exists: boolean; isDirectory?: boolean }> =>
this.post<{ exists: boolean; isDirectory?: boolean }>('/api/validate/path', {
relativePath,
projectPath,
});
validateMentions = (
mentions: { type: 'path'; value: string }[],
projectPath: string
): Promise<Record<string, boolean>> =>
this.post<Record<string, boolean>>('/api/validate/mentions', { mentions, projectPath });
// ---------------------------------------------------------------------------
// CLAUDE.md reading
// ---------------------------------------------------------------------------
readClaudeMdFiles = (projectRoot: string): Promise<Record<string, ClaudeMdFileInfo>> =>
this.post<Record<string, ClaudeMdFileInfo>>('/api/read-claude-md', { projectRoot });
readDirectoryClaudeMd = (dirPath: string): Promise<ClaudeMdFileInfo> =>
this.post<ClaudeMdFileInfo>('/api/read-directory-claude-md', { dirPath });
readMentionedFile = (
absolutePath: string,
projectRoot: string,
maxTokens?: number
): Promise<ClaudeMdFileInfo | null> =>
this.post<ClaudeMdFileInfo | null>('/api/read-mentioned-file', {
absolutePath,
projectRoot,
maxTokens,
});
// ---------------------------------------------------------------------------
// Notifications (nested API)
// ---------------------------------------------------------------------------
notifications: NotificationsAPI = {
get: (options) =>
this.get(
`/api/notifications?${new URLSearchParams(
options
? {
limit: String(options.limit ?? 20),
offset: String(options.offset ?? 0),
}
: {}
)}`
),
markRead: (id) => this.post(`/api/notifications/${encodeURIComponent(id)}/read`),
markAllRead: () => this.post('/api/notifications/read-all'),
delete: (id) => this.del(`/api/notifications/${encodeURIComponent(id)}`),
clear: () => this.del('/api/notifications'),
getUnreadCount: () => this.get('/api/notifications/unread-count'),
// IPC signature: (event: unknown, error: unknown) => void
onNew: (callback) =>
this.addEventListener('notification:new', (data: unknown) => callback(null, data)),
// IPC signature: (event: unknown, payload: { total; unreadCount }) => void
onUpdated: (callback) =>
this.addEventListener('notification:updated', (data: unknown) =>
callback(null, data as { total: number; unreadCount: number })
),
// IPC signature: (event: unknown, data: unknown) => void
onClicked: (callback) =>
this.addEventListener('notification:clicked', (data: unknown) => callback(null, data)),
};
// ---------------------------------------------------------------------------
// Config (nested API)
// ---------------------------------------------------------------------------
config: ConfigAPI = {
get: async (): Promise<AppConfig> => {
const result = await this.get<{ success: boolean; data?: AppConfig; error?: string }>(
'/api/config'
);
if (!result.success) throw new Error(result.error ?? 'Failed to get config');
return result.data!;
},
update: async (section: string, data: object): Promise<AppConfig> => {
const result = await this.post<{ success: boolean; data?: AppConfig; error?: string }>(
'/api/config/update',
{ section, data }
);
if (!result.success) throw new Error(result.error ?? 'Failed to update config');
return result.data!;
},
addIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await this.post('/api/config/ignore-regex', { pattern });
return this.config.get();
},
removeIgnoreRegex: async (pattern: string): Promise<AppConfig> => {
await this.del('/api/config/ignore-regex', { pattern });
return this.config.get();
},
addIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await this.post('/api/config/ignore-repository', { repositoryId });
return this.config.get();
},
removeIgnoreRepository: async (repositoryId: string): Promise<AppConfig> => {
await this.del('/api/config/ignore-repository', { repositoryId });
return this.config.get();
},
snooze: async (minutes: number): Promise<AppConfig> => {
await this.post('/api/config/snooze', { minutes });
return this.config.get();
},
clearSnooze: async (): Promise<AppConfig> => {
await this.post('/api/config/clear-snooze');
return this.config.get();
},
addTrigger: async (trigger): Promise<AppConfig> => {
await this.post('/api/config/triggers', trigger);
return this.config.get();
},
updateTrigger: async (triggerId: string, updates): Promise<AppConfig> => {
await this.put(`/api/config/triggers/${encodeURIComponent(triggerId)}`, updates);
return this.config.get();
},
removeTrigger: async (triggerId: string): Promise<AppConfig> => {
await this.del(`/api/config/triggers/${encodeURIComponent(triggerId)}`);
return this.config.get();
},
getTriggers: async (): Promise<NotificationTrigger[]> => {
const result = await this.get<{ success: boolean; data?: NotificationTrigger[] }>(
'/api/config/triggers'
);
return result.data ?? [];
},
testTrigger: async (trigger: NotificationTrigger): Promise<TriggerTestResult> => {
const result = await this.post<{
success: boolean;
data?: TriggerTestResult;
error?: string;
}>(`/api/config/triggers/${encodeURIComponent(trigger.id)}/test`, trigger);
if (!result.success) throw new Error(result.error ?? 'Failed to test trigger');
return result.data!;
},
selectFolders: async (): Promise<string[]> => {
console.warn('[HttpAPIClient] selectFolders is not available in browser mode');
return [];
},
openInEditor: async (): Promise<void> => {
console.warn('[HttpAPIClient] openInEditor is not available in browser mode');
},
pinSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/pin-session', { projectId, sessionId }),
unpinSession: (projectId: string, sessionId: string): Promise<void> =>
this.post('/api/config/unpin-session', { projectId, sessionId }),
};
// ---------------------------------------------------------------------------
// Session navigation
// ---------------------------------------------------------------------------
session: SessionAPI = {
scrollToLine: (sessionId: string, lineNumber: number): Promise<void> =>
this.post('/api/session/scroll-to-line', { sessionId, lineNumber }),
};
// ---------------------------------------------------------------------------
// Zoom (browser fallbacks)
// ---------------------------------------------------------------------------
getZoomFactor = async (): Promise<number> => 1.0;
onZoomFactorChanged = (_callback: (zoomFactor: number) => void): (() => void) => {
// No-op in browser mode — zoom is managed by the browser itself
return () => {};
};
// ---------------------------------------------------------------------------
// File change events (via SSE)
// ---------------------------------------------------------------------------
onFileChange = (callback: (event: FileChangeEvent) => void): (() => void) =>
this.addEventListener('file-change', callback);
onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) =>
this.addEventListener('todo-change', callback);
// ---------------------------------------------------------------------------
// Shell operations (browser fallbacks)
// ---------------------------------------------------------------------------
openPath = async (
_targetPath: string,
_projectRoot?: string
): Promise<{ success: boolean; error?: string }> => {
console.warn('[HttpAPIClient] openPath is not available in browser mode');
return { success: false, error: 'Not available in browser mode' };
};
openExternal = async (url: string): Promise<{ success: boolean; error?: string }> => {
window.open(url, '_blank');
return { success: true };
};
// ---------------------------------------------------------------------------
// Updater (browser no-ops)
// ---------------------------------------------------------------------------
updater: UpdaterAPI = {
check: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
download: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
install: async (): Promise<void> => {
console.warn('[HttpAPIClient] updater not available in browser mode');
},
onStatus: (_callback): (() => void) => {
return () => {};
},
};
// ---------------------------------------------------------------------------
// SSH
// ---------------------------------------------------------------------------
ssh: SshAPI = {
connect: (config: SshConnectionConfig): Promise<SshConnectionStatus> =>
this.post('/api/ssh/connect', config),
disconnect: (): Promise<SshConnectionStatus> => this.post('/api/ssh/disconnect'),
getState: (): Promise<SshConnectionStatus> => this.get('/api/ssh/state'),
test: (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> =>
this.post('/api/ssh/test', config),
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
const result = await this.get<{ success: boolean; data?: SshConfigHostEntry[] }>(
'/api/ssh/config-hosts'
);
return result.data ?? [];
},
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
const result = await this.post<{
success: boolean;
data?: SshConfigHostEntry | null;
}>('/api/ssh/resolve-host', { alias });
return result.data ?? null;
},
saveLastConnection: (config: SshLastConnection): Promise<void> =>
this.post('/api/ssh/save-last-connection', config),
getLastConnection: async (): Promise<SshLastConnection | null> => {
const result = await this.get<{ success: boolean; data?: SshLastConnection | null }>(
'/api/ssh/last-connection'
);
return result.data ?? null;
},
// IPC signature: (event: unknown, status: SshConnectionStatus) => void
onStatus: (callback): (() => void) =>
this.addEventListener('ssh:status', (data: unknown) =>
callback(null, data as SshConnectionStatus)
),
};
// ---------------------------------------------------------------------------
// Context API
// ---------------------------------------------------------------------------
context = {
list: (): Promise<ContextInfo[]> => this.get<ContextInfo[]>('/api/contexts'),
getActive: (): Promise<string> => this.get<string>('/api/contexts/active'),
switch: (contextId: string): Promise<{ contextId: string }> =>
this.post<{ contextId: string }>('/api/contexts/switch', { contextId }),
onChanged: (callback: (event: unknown, data: ContextInfo) => void): (() => void) =>
this.addEventListener('context:changed', (data: unknown) =>
callback(null, data as ContextInfo)
),
};
// HTTP Server API — in browser mode, server is already running (we're using it)
httpServer: HttpServerAPI = {
start: (): Promise<HttpServerStatus> =>
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
stop: (): Promise<HttpServerStatus> => {
console.warn('[HttpAPIClient] Cannot stop HTTP server from browser mode');
return Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) });
},
getStatus: (): Promise<HttpServerStatus> =>
Promise.resolve({ running: true, port: parseInt(new URL(this.baseUrl).port, 10) }),
};
}

51
src/renderer/api/index.ts Normal file
View file

@ -0,0 +1,51 @@
/**
* Unified API adapter.
*
* When running inside Electron, the preload script exposes `window.electronAPI`.
* When running in a browser (e.g. via the HTTP server), we fall back to an
* HTTP+SSE client that implements the same interface.
*
* All renderer code should import `api` from this module instead of
* accessing `window.electronAPI` directly.
*
* The instance is resolved lazily on first property access so that test code
* can install mocks on `window.electronAPI` before the adapter resolves.
*/
import { HttpAPIClient } from './httpClient';
import type { ElectronAPI } from '@shared/types/api';
function getHttpPort(): number {
const params = new URLSearchParams(window.location.search);
return parseInt(params.get('port') ?? '3456', 10);
}
let httpClient: HttpAPIClient | null = null;
function getImpl(): ElectronAPI {
if (window.electronAPI) return window.electronAPI;
// Lazily create the HTTP client only when actually needed (browser mode).
// Caching avoids creating multiple EventSource connections.
if (!httpClient) {
httpClient = new HttpAPIClient(getHttpPort());
}
return httpClient;
}
/**
* Proxy that lazily resolves the underlying ElectronAPI on first property access.
* In Electron: delegates to `window.electronAPI` (set by preload).
* In browser: delegates to `HttpAPIClient` (created on first use).
* In tests: delegates to whatever mock is installed on `window.electronAPI`.
*/
export const api: ElectronAPI = new Proxy({} as ElectronAPI, {
get(_target, prop, receiver) {
const impl = getImpl();
const value = Reflect.get(impl, prop, receiver) as unknown;
if (typeof value === 'function') {
return (value as (...args: unknown[]) => unknown).bind(impl);
}
return value;
},
});

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import { api } from '@renderer/api';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
@ -360,7 +361,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
const validatePaths = async (): Promise<void> => {
try {
const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value }));
const results = await window.electronAPI.validateMentions(toValidate, projectPath);
const results = await api.validateMentions(toValidate, projectPath);
if (isCurrent) {
setValidatedPaths(results);
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
import {
CODE_BG,
@ -107,7 +108,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
onClick={(e) => {
e.preventDefault();
if (href) {
void window.electronAPI.openExternal(href);
void api.openExternal(href);
}
}}
>

View file

@ -9,6 +9,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
@ -203,7 +204,7 @@ const NewProjectCard = (): React.JSX.Element => {
const handleClick = async (): Promise<void> => {
try {
const selectedPaths = await window.electronAPI.config.selectFolders();
const selectedPaths = await api.config.selectFolders();
if (!selectedPaths || selectedPaths.length === 0) {
return; // User cancelled
}
@ -221,7 +222,7 @@ const NewProjectCard = (): React.JSX.Element => {
}
// No match found - open the folder in file manager as fallback
const result = await window.electronAPI.openPath(selectedPath);
const result = await api.openPath(selectedPath);
if (!result.success) {
logger.error('Failed to open folder:', result.error);
}

View file

@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
@ -220,11 +221,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
latestSearchRequestRef.current = requestId;
setLoading(true);
try {
const searchResult = await window.electronAPI.searchSessions(
selectedProjectId,
query.trim(),
50
);
const searchResult = await api.searchSessions(selectedProjectId, query.trim(), 50);
if (latestSearchRequestRef.current !== requestId) {
return;
}

View file

@ -4,6 +4,7 @@
import { useCallback, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
@ -91,7 +92,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
setPreviewResult({ loading: true, totalCount: 0, errors: [] });
try {
const result = await window.electronAPI.config.testTrigger(trigger);
const result = await api.config.testTrigger(trigger);
setPreviewResult({
loading: false,
totalCount: result.totalCount,

View file

@ -5,6 +5,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
@ -87,7 +88,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
try {
setLoading(true);
setError(null);
const loadedConfig = await window.electronAPI.config.get();
const loadedConfig = await api.config.get();
setConfig(loadedConfig);
setOptimisticConfig(loadedConfig);
} catch (err) {
@ -124,7 +125,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.update(section, data);
const updatedConfig = await api.config.update(section, data as object);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
// Update global store so other components (like useTheme) see the change

View file

@ -5,6 +5,7 @@
import { useCallback, useRef } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import type { RepositoryDropdownItem } from './useSettingsConfig';
@ -99,7 +100,7 @@ export function useSettingsHandlers({
async (minutes: number) => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.snooze(minutes);
const updatedConfig = await api.config.snooze(minutes);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -115,7 +116,7 @@ export function useSettingsHandlers({
const handleClearSnooze = useCallback(async () => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.clearSnooze();
const updatedConfig = await api.config.clearSnooze();
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -130,7 +131,7 @@ export function useSettingsHandlers({
async (item: RepositoryDropdownItem) => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.addIgnoreRepository(item.id);
const updatedConfig = await api.config.addIgnoreRepository(item.id);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -147,7 +148,7 @@ export function useSettingsHandlers({
async (repositoryId: string) => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.removeIgnoreRepository(repositoryId);
const updatedConfig = await api.config.removeIgnoreRepository(repositoryId);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -165,7 +166,7 @@ export function useSettingsHandlers({
async (trigger: Omit<NotificationTrigger, 'isBuiltin'>) => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.addTrigger(trigger);
const updatedConfig = await api.config.addTrigger(trigger);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -198,7 +199,7 @@ export function useSettingsHandlers({
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.updateTrigger(triggerId, updates);
const updatedConfig = await api.config.updateTrigger(triggerId, updates);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -217,7 +218,7 @@ export function useSettingsHandlers({
async (triggerId: string) => {
try {
setSaving(true);
const updatedConfig = await window.electronAPI.config.removeTrigger(triggerId);
const updatedConfig = await api.config.removeTrigger(triggerId);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -296,12 +297,9 @@ export function useSettingsHandlers({
},
};
await window.electronAPI.config.update('notifications', defaultConfig.notifications);
await window.electronAPI.config.update('general', defaultConfig.general);
const updatedConfig = await window.electronAPI.config.update(
'display',
defaultConfig.display
);
await api.config.update('notifications', defaultConfig.notifications);
await api.config.update('general', defaultConfig.general);
const updatedConfig = await api.config.update('display', defaultConfig.display);
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });
@ -328,7 +326,7 @@ export function useSettingsHandlers({
const handleOpenInEditor = useCallback(async () => {
try {
await window.electronAPI.config.openInEditor();
await api.config.openInEditor();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open config in editor');
}
@ -348,16 +346,16 @@ export function useSettingsHandlers({
const importedConfig = JSON.parse(text) as AppConfig;
if (importedConfig.notifications) {
await window.electronAPI.config.update('notifications', importedConfig.notifications);
await api.config.update('notifications', importedConfig.notifications);
}
if (importedConfig.general) {
await window.electronAPI.config.update('general', importedConfig.general);
await api.config.update('general', importedConfig.general);
}
if (importedConfig.display) {
await window.electronAPI.config.update('display', importedConfig.display);
await api.config.update('display', importedConfig.display);
}
const updatedConfig = await window.electronAPI.config.get();
const updatedConfig = await api.config.get();
setConfig(updatedConfig);
setOptimisticConfig(updatedConfig);
setStoreState({ appConfig: updatedConfig });

View file

@ -4,6 +4,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store';
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
@ -44,7 +45,7 @@ export const AdvancedSection = ({
}, [updateStatus]);
useEffect(() => {
window.electronAPI.getAppVersion().then(setVersion).catch(console.error);
api.getAppVersion().then(setVersion).catch(console.error);
}, []);
const handleCheckForUpdates = useCallback(() => {

View file

@ -1,10 +1,16 @@
/**
* GeneralSection - General settings including startup and appearance.
* GeneralSection - General settings including startup, appearance, and browser access.
*/
import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { Check, Copy, Loader2 } from 'lucide-react';
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
import type { SafeConfig } from '../hooks/useSettingsConfig';
import type { HttpServerStatus } from '@shared/types/api';
// Theme options
const THEME_OPTIONS = [
@ -26,6 +32,37 @@ export const GeneralSection = ({
onGeneralToggle,
onThemeChange,
}: GeneralSectionProps): React.JSX.Element => {
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({ running: false, port: 3456 });
const [serverLoading, setServerLoading] = useState(false);
const [copied, setCopied] = useState(false);
// Fetch server status on mount
useEffect(() => {
void api.httpServer.getStatus().then(setServerStatus);
}, []);
const handleServerToggle = useCallback(async (enabled: boolean) => {
setServerLoading(true);
try {
const status = enabled
? await api.httpServer.start()
: await api.httpServer.stop();
setServerStatus(status);
} catch {
// Status didn't change
} finally {
setServerLoading(false);
}
}, []);
const serverUrl = `http://localhost:${serverStatus.port}`;
const handleCopyUrl = useCallback(() => {
void navigator.clipboard.writeText(serverUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [serverUrl]);
return (
<div>
<SettingsSectionHeader title="Startup" />
@ -55,6 +92,58 @@ export const GeneralSection = ({
disabled={saving}
/>
</SettingRow>
<SettingsSectionHeader title="Browser Access" />
<SettingRow
label="Enable server mode"
description="Start an HTTP server to access the UI from a browser or embed in iframes"
>
{serverLoading ? (
<Loader2 className="size-5 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
) : (
<SettingsToggle
enabled={serverStatus.running}
onChange={handleServerToggle}
disabled={saving}
/>
)}
</SettingRow>
{serverStatus.running && (
<div
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
style={{ backgroundColor: 'var(--color-surface-raised)' }}
>
<div
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: '#22c55e' }}
/>
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Running on
</span>
<code
className="rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-text)',
border: '1px solid var(--color-border)',
}}
>
{serverUrl}
</code>
<button
onClick={handleCopyUrl}
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
style={{
borderColor: 'var(--color-border)',
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? 'Copied' : 'Copy URL'}
</button>
</div>
)}
</div>
);
};

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
/**
* Reads current zoom factor and stays subscribed to zoom updates from main.
*/
@ -9,7 +11,7 @@ export function useZoomFactor(): number {
useEffect(() => {
let isMounted = true;
void window.electronAPI
void api
.getZoomFactor()
.then((value) => {
if (isMounted) {
@ -20,7 +22,7 @@ export function useZoomFactor(): number {
// Keep default 1 if zoom factor cannot be read.
});
const unsubscribe = window.electronAPI.onZoomFactorChanged((value) => {
const unsubscribe = api.onZoomFactorChanged((value) => {
setZoomFactor(value);
});

View file

@ -2,6 +2,7 @@
* Store index - combines all slices and exports the unified store.
*/
import { api } from '@renderer/api';
import { create } from 'zustand';
import { createConfigSlice } from './slices/configSlice';
@ -93,8 +94,8 @@ export function initializeNotificationListeners(): () => void {
};
// Listen for new notifications from main process
if (window.electronAPI.notifications?.onNew) {
const cleanup = window.electronAPI.notifications.onNew((_event: unknown, error: unknown) => {
if (api.notifications?.onNew) {
const cleanup = api.notifications.onNew((_event: unknown, error: unknown) => {
// Cast the error to DetectedError type
const notification = error as DetectedError;
if (notification?.id) {
@ -113,8 +114,8 @@ export function initializeNotificationListeners(): () => void {
}
// Listen for notification updates from main process
if (window.electronAPI.notifications?.onUpdated) {
const cleanup = window.electronAPI.notifications.onUpdated(
if (api.notifications?.onUpdated) {
const cleanup = api.notifications.onUpdated(
(_event: unknown, payload: { total: number; unreadCount: number }) => {
const unreadCount =
typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount)
@ -129,8 +130,8 @@ export function initializeNotificationListeners(): () => void {
}
// Navigate to error when user clicks a native OS notification
if (window.electronAPI.notifications?.onClicked) {
const cleanup = window.electronAPI.notifications.onClicked((_event: unknown, data: unknown) => {
if (api.notifications?.onClicked) {
const cleanup = api.notifications.onClicked((_event: unknown, data: unknown) => {
const error = data as DetectedError;
if (error?.id && error?.sessionId && error?.projectId) {
useStore.getState().navigateToError(error);
@ -161,8 +162,8 @@ export function initializeNotificationListeners(): () => void {
};
// Listen for task-list file changes to refresh currently viewed session metadata
if (window.electronAPI.onTodoChange) {
const cleanup = window.electronAPI.onTodoChange((event) => {
if (api.onTodoChange) {
const cleanup = api.onTodoChange((event) => {
if (!event.sessionId || event.type === 'unlink') {
return;
}
@ -198,8 +199,8 @@ export function initializeNotificationListeners(): () => void {
}
// Listen for file changes to auto-refresh current session and detect new sessions
if (window.electronAPI.onFileChange) {
const cleanup = window.electronAPI.onFileChange((event) => {
if (api.onFileChange) {
const cleanup = api.onFileChange((event) => {
// Skip unlink events
if (event.type === 'unlink') {
return;
@ -234,8 +235,8 @@ export function initializeNotificationListeners(): () => void {
}
// Listen for updater status events from main process
if (window.electronAPI.updater?.onStatus) {
const cleanup = window.electronAPI.updater.onStatus((_event: unknown, status: unknown) => {
if (api.updater?.onStatus) {
const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => {
const s = status as UpdaterStatus;
switch (s.type) {
case 'checking':
@ -279,8 +280,8 @@ export function initializeNotificationListeners(): () => void {
}
// Listen for SSH connection status changes from main process
if (window.electronAPI.ssh?.onStatus) {
const cleanup = window.electronAPI.ssh.onStatus((_event: unknown, status: unknown) => {
if (api.ssh?.onStatus) {
const cleanup = api.ssh.onStatus((_event: unknown, status: unknown) => {
const s = status as { state: string; host: string | null; error: string | null };
useStore
.getState()

View file

@ -2,6 +2,7 @@
* Config slice - manages app configuration state and actions.
*/
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types';
@ -40,7 +41,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
fetchConfig: async () => {
set({ configLoading: true, configError: null });
try {
const config = await window.electronAPI.config.get();
const config = await api.config.get();
set({
appConfig: config,
configLoading: false,
@ -56,9 +57,9 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
// Update a section of the app configuration
updateConfig: async (section: string, data: Record<string, unknown>) => {
try {
await window.electronAPI.config.update(section, data);
await api.config.update(section, data);
// Refresh config after update
const config = await window.electronAPI.config.get();
const config = await api.config.get();
set({ appConfig: config });
} catch (error) {
logger.error('Failed to update config:', error);

View file

@ -5,6 +5,8 @@
* and provides actions for connecting/disconnecting.
*/
import { api } from '@renderer/api';
import { getFullResetState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
@ -68,7 +70,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
});
try {
const status = await window.electronAPI.ssh.connect(config);
const status = await api.ssh.connect(config);
set({
connectionMode: status.state === 'connected' ? 'ssh' : 'local',
connectionState: status.state,
@ -93,7 +95,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
privateKeyPath: config.privateKeyPath,
};
set({ lastSshConfig: saved });
void window.electronAPI.ssh.saveLastConnection(saved);
void api.ssh.saveLastConnection(saved);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@ -106,7 +108,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
disconnectSsh: async (): Promise<void> => {
try {
const status = await window.electronAPI.ssh.disconnect();
const status = await api.ssh.disconnect();
set({
connectionMode: 'local',
connectionState: status.state,
@ -130,7 +132,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
config: SshConnectionConfig
): Promise<{ success: boolean; error?: string }> => {
try {
return await window.electronAPI.ssh.test(config);
return await api.ssh.test(config);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: message };
@ -152,7 +154,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
fetchSshConfigHosts: async (): Promise<void> => {
try {
const hosts = await window.electronAPI.ssh.getConfigHosts();
const hosts = await api.ssh.getConfigHosts();
set({ sshConfigHosts: hosts });
} catch {
// Gracefully ignore - SSH config may not exist
@ -162,7 +164,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
try {
return await window.electronAPI.ssh.resolveHost(alias);
return await api.ssh.resolveHost(alias);
} catch {
return null;
}
@ -170,7 +172,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
loadLastConnection: async (): Promise<void> => {
try {
const saved = await window.electronAPI.ssh.getLastConnection();
const saved = await api.ssh.getLastConnection();
set({ lastSshConfig: saved });
} catch {
// Gracefully ignore - no saved connection

View file

@ -2,6 +2,7 @@
* Notification slice - manages notifications state and actions.
*/
import { api } from '@renderer/api';
import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs';
import { createLogger } from '@shared/utils/logger';
@ -54,7 +55,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
set({ notificationsLoading: true, notificationsError: null });
try {
// Fetch the full stored history (manager currently caps storage at 100).
const result = await window.electronAPI.notifications.get({
const result = await api.notifications.get({
limit: NOTIFICATIONS_FETCH_LIMIT,
offset: 0,
});
@ -80,7 +81,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
// Mark a single notification as read
markNotificationRead: async (id: string) => {
try {
const success = await window.electronAPI.notifications.markRead(id);
const success = await api.notifications.markRead(id);
if (!success) {
await get().fetchNotifications();
return;
@ -101,7 +102,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
// Mark all notifications as read
markAllNotificationsRead: async () => {
try {
const success = await window.electronAPI.notifications.markAllRead();
const success = await api.notifications.markAllRead();
if (!success) {
await get().fetchNotifications();
return;
@ -119,7 +120,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
// Delete a single notification
deleteNotification: async (id: string) => {
try {
const success = await window.electronAPI.notifications.delete(id);
const success = await api.notifications.delete(id);
if (!success) {
await get().fetchNotifications();
return;
@ -138,7 +139,7 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
// Clear all notifications
clearNotifications: async () => {
try {
const success = await window.electronAPI.notifications.clear();
const success = await api.notifications.clear();
if (!success) {
await get().fetchNotifications();
return;

View file

@ -2,6 +2,8 @@
* Project slice - manages project list state and selection.
*/
import { api } from '@renderer/api';
import { getSessionResetState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
@ -39,7 +41,7 @@ export const createProjectSlice: StateCreator<AppState, [], [], ProjectSlice> =
fetchProjects: async () => {
set({ projectsLoading: true, projectsError: null });
try {
const projects = await window.electronAPI.getProjects();
const projects = await api.getProjects();
// Sort by most recent session (descending)
const sorted = [...projects].sort(
(a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)

View file

@ -2,6 +2,7 @@
* Repository slice - manages repository grouping state (worktree support).
*/
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import { getSessionResetState } from '../utils/stateResetHelpers';
@ -52,7 +53,7 @@ export const createRepositorySlice: StateCreator<AppState, [], [], RepositorySli
fetchRepositoryGroups: async () => {
set({ repositoryGroupsLoading: true, repositoryGroupsError: null });
try {
const groups = await window.electronAPI.getRepositoryGroups();
const groups = await api.getRepositoryGroups();
// Already sorted by most recent session in the scanner
set({ repositoryGroups: groups, repositoryGroupsLoading: false });
} catch (error) {

View file

@ -2,6 +2,7 @@
* Session detail slice - manages session detail, conversation, and stats.
*/
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { findTabBySession, truncateLabel } from '@renderer/types/tabs';
import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker';
@ -163,7 +164,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
});
}
try {
const detail = await window.electronAPI.getSessionDetail(projectId, sessionId);
const detail = await api.getSessionDetail(projectId, sessionId);
if (requestGeneration !== sessionDetailFetchGeneration) {
return;
}
@ -192,7 +193,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// Fetch real CLAUDE.md token data
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
try {
claudeMdTokenData = await window.electronAPI.readClaudeMdFiles(projectRoot);
claudeMdTokenData = await api.readClaudeMdFiles(projectRoot);
if (requestGeneration !== sessionDetailFetchGeneration) {
return;
}
@ -227,7 +228,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
Array.from(directoryPaths).map(async (fullPath) => {
try {
const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, '');
const fileInfo = await window.electronAPI.readDirectoryClaudeMd(dirPath);
const fileInfo = await api.readDirectoryClaudeMd(dirPath);
return { fullPath, fileInfo, error: false };
} catch (err) {
logger.error('Failed to read directory CLAUDE.md:', fullPath, err);
@ -324,7 +325,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
const mentionedFileResults = await Promise.all(
Array.from(mentionedFilePaths).map(async (filePath) => {
try {
const fileInfo = await window.electronAPI.readMentionedFile(filePath, projectRoot);
const fileInfo = await api.readMentionedFile(filePath, projectRoot);
return { filePath, fileInfo };
} catch (err) {
logger.error('Failed to read mentioned file:', filePath, err);
@ -471,7 +472,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
sessionRefreshInFlight.add(refreshKey);
try {
const detail = await window.electronAPI.getSessionDetail(projectId, sessionId);
const detail = await api.getSessionDetail(projectId, sessionId);
// Drop stale responses if a newer refresh started while this one was in flight.
if (sessionRefreshGeneration.get(refreshKey) !== generation) {

View file

@ -2,6 +2,7 @@
* Session slice - manages session list state and pagination.
*/
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types';
@ -71,7 +72,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
fetchSessions: async (projectId: string) => {
set({ sessionsLoading: true, sessionsError: null });
try {
const sessions = await window.electronAPI.getSessions(projectId);
const sessions = await api.getSessions(projectId);
// Sort by createdAt (descending)
const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt);
set({ sessions: sorted, sessionsLoading: false });
@ -94,7 +95,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
sessionsTotalCount: 0,
});
try {
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
const result = await api.getSessionsPaginated(projectId, null, 20, {
includeTotalCount: false,
prefilterAll: false,
});
@ -128,15 +129,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
set({ sessionsLoadingMore: true });
try {
const result = await window.electronAPI.getSessionsPaginated(
selectedProjectId,
sessionsCursor,
20,
{
includeTotalCount: false,
prefilterAll: false,
}
);
const result = await api.getSessionsPaginated(selectedProjectId, sessionsCursor, 20, {
includeTotalCount: false,
prefilterAll: false,
});
set((prevState) => ({
sessions: [...prevState.sessions, ...result.sessions],
sessionsCursor: result.nextCursor,
@ -208,7 +204,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
projectRefreshGeneration.set(projectId, generation);
try {
const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, {
const result = await api.getSessionsPaginated(projectId, null, 20, {
includeTotalCount: false,
prefilterAll: false,
});
@ -242,10 +238,10 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
try {
if (isPinned) {
await window.electronAPI.config.unpinSession(projectId, sessionId);
await api.config.unpinSession(projectId, sessionId);
set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) });
} else {
await window.electronAPI.config.pinSession(projectId, sessionId);
await api.config.pinSession(projectId, sessionId);
set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] });
}
} catch (error) {
@ -263,7 +259,7 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
}
try {
const config = await window.electronAPI.config.get();
const config = await api.config.get();
const pins = config.sessions?.pinnedSessions?.[projectId] ?? [];
set({ pinnedSessionIds: pins.map((p) => p.sessionId) });
} catch (error) {

View file

@ -2,6 +2,8 @@
* Subagent slice - manages subagent drill-down state.
*/
import { api } from '@renderer/api';
import type { AppState, BreadcrumbItem } from '../types';
import type { SubagentDetail } from '@renderer/types/data';
import type { StateCreator } from 'zustand';
@ -48,7 +50,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
) => {
set({ subagentDetailLoading: true, subagentDetailError: null });
try {
const detail = await window.electronAPI.getSubagentDetail(projectId, sessionId, subagentId);
const detail = await api.getSubagentDetail(projectId, sessionId, subagentId);
if (!detail) {
set({
@ -108,7 +110,7 @@ export const createSubagentSlice: StateCreator<AppState, [], [], SubagentSlice>
set({ subagentDetailLoading: true, subagentDetailError: null });
window.electronAPI
api
.getSubagentDetail(projectId, sessionId, targetItem.id)
.then((detail) => {
if (detail) {

View file

@ -2,6 +2,7 @@
* Update slice - manages OTA auto-update state and actions.
*/
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types';
@ -54,20 +55,20 @@ export const createUpdateSlice: StateCreator<AppState, [], [], UpdateSlice> = (s
checkForUpdates: () => {
set({ updateStatus: 'checking', updateError: null });
window.electronAPI.updater.check().catch((error) => {
api.updater.check().catch((error) => {
logger.error('Failed to check for updates:', error);
});
},
downloadUpdate: () => {
set({ showUpdateDialog: false, showUpdateBanner: true, downloadProgress: 0 });
window.electronAPI.updater.download().catch((error) => {
api.updater.download().catch((error) => {
logger.error('Failed to download update:', error);
});
},
installUpdate: () => {
window.electronAPI.updater.install().catch((error) => {
api.updater.install().catch((error) => {
logger.error('Failed to install update:', error);
});
},

View file

@ -242,6 +242,27 @@ export interface SshAPI {
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
}
// =============================================================================
// HTTP Server API
// =============================================================================
/**
* HTTP server status returned from main process.
*/
export interface HttpServerStatus {
running: boolean;
port: number;
}
/**
* HTTP Server API for controlling the sidecar server.
*/
export interface HttpServerAPI {
start: () => Promise<HttpServerStatus>;
stop: () => Promise<HttpServerStatus>;
getStatus: () => Promise<HttpServerStatus>;
}
// =============================================================================
// Main Electron API
// =============================================================================
@ -334,6 +355,9 @@ export interface ElectronAPI {
switch: (contextId: string) => Promise<{ contextId: string }>;
onChanged: (callback: (event: unknown, data: ContextInfo) => void) => () => void;
};
// HTTP Server API
httpServer: HttpServerAPI;
}
// =============================================================================

View file

@ -275,4 +275,11 @@ export interface AppConfig {
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
};
/** HTTP sidecar server settings for iframe embedding */
httpServer?: {
/** Whether the HTTP server is enabled */
enabled: boolean;
/** Port for the HTTP server (default 3456) */
port: number;
};
}