Files
Maestro/src/main/index.ts
Pedram Amini e3f8beae45 MAESTRO: Add thinking-chunk IPC event for streaming AI reasoning (Phase 3)
Implement the IPC infrastructure for Show Thinking feature:
- Emit 'thinking-chunk' event in process-manager.ts when partial text events arrive
- Forward thinking-chunk events to renderer via index.ts
- Add onThinkingChunk handler to preload.ts process API
- Add TypeScript interface declaration for onThinkingChunk

This enables the renderer to receive real-time streaming content from AI agents
(Claude Code, OpenCode, Codex) for display when the tab's showThinking setting
is enabled. The renderer decides whether to display based on per-tab settings.
2025-12-22 18:25:23 -06:00

2342 lines
89 KiB
TypeScript

import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import fsSync from 'fs';
import * as Sentry from '@sentry/electron/main';
import { ProcessManager } from './process-manager';
import { WebServer } from './web-server';
import { AgentDetector } from './agent-detector';
import { logger } from './utils/logger';
import { tunnelManager } from './tunnel-manager';
import { getThemeById } from './themes';
import Store from 'electron-store';
import { getHistoryManager } from './history-manager';
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, registerDebugHandlers, setupLoggerEventForwarding } from './ipc/handlers';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import { routeModeratorResponse, routeAgentResponse, setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState } from './group-chat/group-chat-router';
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
import { initializeSessionStorages } from './storage';
import { initializeOutputParsers, getOutputParser } from './parsers';
import { DEMO_MODE, DEMO_DATA_PATH } from './constants';
import { initAutoUpdater } from './auto-updater';
// ============================================================================
// Custom Storage Location Configuration
// ============================================================================
// This bootstrap store is ALWAYS local - it tells us where to find the main data
// Users can choose a custom folder (e.g., iCloud Drive, Dropbox, OneDrive) to sync settings
interface BootstrapSettings {
customSyncPath?: string;
iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration
}
const bootstrapStore = new Store<BootstrapSettings>({
name: 'maestro-bootstrap',
defaults: {},
});
/**
* Get the custom sync path if configured.
* Returns undefined if using default path.
*/
function getSyncPath(): string | undefined {
const customPath = bootstrapStore.get('customSyncPath');
if (customPath) {
// Ensure the directory exists
if (!fsSync.existsSync(customPath)) {
try {
fsSync.mkdirSync(customPath, { recursive: true });
} catch {
// If we can't create the directory, fall back to default
console.error(`Failed to create custom sync path: ${customPath}, using default`);
return undefined;
}
}
return customPath;
}
return undefined; // Use default path
}
// Get the sync path once at startup
const syncPath = getSyncPath();
// Initialize Sentry for crash reporting (before app.ready)
// Only enable in production - skip during development to avoid noise from hot-reload artifacts
const isDevelopment = process.env.NODE_ENV === 'development';
// Check if crash reporting is enabled (default: true for opt-out behavior)
const crashReportingStore = new Store<{ crashReportingEnabled: boolean }>({
name: 'maestro-settings',
});
const crashReportingEnabled = crashReportingStore.get('crashReportingEnabled', true);
if (crashReportingEnabled && !isDevelopment) {
Sentry.init({
dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568',
// Set release version for better debugging
release: app.getVersion(),
// Only send errors, not performance data
tracesSampleRate: 0,
// Filter out sensitive data
beforeSend(event) {
// Remove any potential sensitive data from the event
if (event.user) {
delete event.user.ip_address;
delete event.user.email;
}
return event;
},
});
}
// Demo mode: use a separate data directory for fresh demos
if (DEMO_MODE) {
app.setPath('userData', DEMO_DATA_PATH);
console.log(`[DEMO MODE] Using data directory: ${DEMO_DATA_PATH}`);
}
// Type definitions
interface MaestroSettings {
activeThemeId: string;
llmProvider: string;
modelSlug: string;
apiKey: string;
shortcuts: Record<string, any>;
fontSize: number;
fontFamily: string;
customFonts: string[];
logLevel: 'debug' | 'info' | 'warn' | 'error';
defaultShell: string;
// Web interface authentication
webAuthEnabled: boolean;
webAuthToken: string | null;
// Web interface custom port
webInterfaceUseCustomPort: boolean;
webInterfaceCustomPort: number;
}
const store = new Store<MaestroSettings>({
name: 'maestro-settings',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
activeThemeId: 'dracula',
llmProvider: 'openrouter',
modelSlug: 'anthropic/claude-3.5-sonnet',
apiKey: '',
shortcuts: {},
fontSize: 14,
fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace',
customFonts: [],
logLevel: 'info',
defaultShell: 'zsh',
webAuthEnabled: false,
webAuthToken: null,
webInterfaceUseCustomPort: false,
webInterfaceCustomPort: 8080,
},
});
// Sessions store
interface SessionsData {
sessions: any[];
}
const sessionsStore = new Store<SessionsData>({
name: 'maestro-sessions',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
sessions: [],
},
});
// Groups store
interface GroupsData {
groups: any[];
}
const groupsStore = new Store<GroupsData>({
name: 'maestro-groups',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
groups: [],
},
});
interface AgentConfigsData {
configs: Record<string, Record<string, any>>; // agentId -> config key-value pairs
}
const agentConfigsStore = new Store<AgentConfigsData>({
name: 'maestro-agent-configs',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
configs: {},
},
});
// Window state store (for remembering window size/position)
// NOTE: This is intentionally NOT synced - window state is per-device
interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
isFullScreen: boolean;
}
const windowStateStore = new Store<WindowState>({
name: 'maestro-window-state',
// No cwd - always local, not synced (window position is device-specific)
defaults: {
width: 1400,
height: 900,
isMaximized: false,
isFullScreen: false,
},
});
// Note: History storage is now handled by HistoryManager which uses per-session files
// in the history/ directory. The legacy maestro-history.json file is migrated automatically.
// See src/main/history-manager.ts for details.
// Claude session origins store - tracks which Claude sessions were created by Maestro
// and their origin type (user-initiated vs auto/batch)
type ClaudeSessionOrigin = 'user' | 'auto';
interface ClaudeSessionOriginInfo {
origin: ClaudeSessionOrigin;
sessionName?: string; // User-defined session name from Maestro
starred?: boolean; // Whether the session is starred
}
interface ClaudeSessionOriginsData {
// Map of projectPath -> { agentSessionId -> origin info }
origins: Record<string, Record<string, ClaudeSessionOrigin | ClaudeSessionOriginInfo>>;
}
const claudeSessionOriginsStore = new Store<ClaudeSessionOriginsData>({
name: 'maestro-claude-session-origins',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
origins: {},
},
});
// Generic agent session origins store - supports all agents (Codex, OpenCode, etc.)
// Structure: { [agentId]: { [projectPath]: { [sessionId]: { origin, sessionName, starred } } } }
interface AgentSessionOriginsData {
origins: Record<string, Record<string, Record<string, { origin?: 'user' | 'auto'; sessionName?: string; starred?: boolean }>>>;
}
const agentSessionOriginsStore = new Store<AgentSessionOriginsData>({
name: 'maestro-agent-session-origins',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
origins: {},
},
});
let mainWindow: BrowserWindow | null = null;
let processManager: ProcessManager | null = null;
let webServer: WebServer | null = null;
let agentDetector: AgentDetector | null = null;
let cliActivityWatcher: fsSync.FSWatcher | null = null;
/**
* Create and configure the web server with all necessary callbacks.
* Called when user enables the web interface.
*/
function createWebServer(): WebServer {
// Use custom port if enabled, otherwise 0 for random port assignment
const useCustomPort = store.get('webInterfaceUseCustomPort', false);
const customPort = store.get('webInterfaceCustomPort', 8080);
const port = useCustomPort ? customPort : 0;
const server = new WebServer(port); // Custom or random port with auto-generated security token
// Set up callback for web server to fetch sessions list
server.setGetSessionsCallback(() => {
const sessions = sessionsStore.get('sessions', []);
const groups = groupsStore.get('groups', []);
return sessions.map((s: any) => {
// Find the group for this session
const group = s.groupId ? groups.find((g: any) => g.id === s.groupId) : null;
// Extract last AI response for mobile preview (first 3 lines, max 500 chars)
// Use active tab's logs as the source of truth
let lastResponse = null;
const activeTab = s.aiTabs?.find((t: any) => t.id === s.activeTabId) || s.aiTabs?.[0];
const tabLogs = activeTab?.logs || [];
if (tabLogs.length > 0) {
// Find the last stdout/stderr entry from the AI (not user messages)
const lastAiLog = [...tabLogs].reverse().find((log: any) =>
log.source === 'stdout' || log.source === 'stderr'
);
if (lastAiLog && lastAiLog.text) {
const fullText = lastAiLog.text;
// Get first 3 lines or 500 chars, whichever is shorter
const lines = fullText.split('\n').slice(0, 3);
let previewText = lines.join('\n');
if (previewText.length > 500) {
previewText = previewText.slice(0, 497) + '...';
} else if (fullText.length > previewText.length) {
previewText = previewText + '...';
}
lastResponse = {
text: previewText,
timestamp: lastAiLog.timestamp,
source: lastAiLog.source,
fullLength: fullText.length,
};
}
}
// Map aiTabs to web-safe format (strip logs to reduce payload)
const aiTabs = s.aiTabs?.map((tab: any) => ({
id: tab.id,
agentSessionId: tab.agentSessionId || null,
name: tab.name || null,
starred: tab.starred || false,
inputValue: tab.inputValue || '',
usageStats: tab.usageStats || null,
createdAt: tab.createdAt,
state: tab.state || 'idle',
thinkingStartTime: tab.thinkingStartTime || null,
})) || [];
return {
id: s.id,
name: s.name,
toolType: s.toolType,
state: s.state,
inputMode: s.inputMode,
cwd: s.cwd,
groupId: s.groupId || null,
groupName: group?.name || null,
groupEmoji: group?.emoji || null,
usageStats: s.usageStats || null,
lastResponse,
agentSessionId: s.agentSessionId || null,
thinkingStartTime: s.thinkingStartTime || null,
aiTabs,
activeTabId: s.activeTabId || (aiTabs.length > 0 ? aiTabs[0].id : undefined),
bookmarked: s.bookmarked || false,
};
});
});
// Set up callback for web server to fetch single session details
// Optional tabId param allows fetching logs for a specific tab (avoids race conditions)
server.setGetSessionDetailCallback((sessionId: string, tabId?: string) => {
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) return null;
// Get the requested tab's logs (or active tab if no tabId provided)
// Tabs are the source of truth for AI conversation history
let aiLogs: any[] = [];
const targetTabId = tabId || session.activeTabId;
if (session.aiTabs && session.aiTabs.length > 0) {
const targetTab = session.aiTabs.find((t: any) => t.id === targetTabId) || session.aiTabs[0];
aiLogs = targetTab?.logs || [];
}
return {
id: session.id,
name: session.name,
toolType: session.toolType,
state: session.state,
inputMode: session.inputMode,
cwd: session.cwd,
aiLogs,
shellLogs: session.shellLogs || [],
usageStats: session.usageStats,
agentSessionId: session.agentSessionId,
isGitRepo: session.isGitRepo,
activeTabId: targetTabId,
};
});
// Set up callback for web server to fetch current theme
server.setGetThemeCallback(() => {
const themeId = store.get('activeThemeId', 'dracula');
return getThemeById(themeId);
});
// Set up callback for web server to fetch custom AI commands
server.setGetCustomCommandsCallback(() => {
const customCommands = store.get('customAICommands', []) as Array<{
id: string;
command: string;
description: string;
prompt: string;
}>;
return customCommands;
});
// Set up callback for web server to fetch history entries
// Uses HistoryManager for per-session storage
server.setGetHistoryCallback((projectPath?: string, sessionId?: string) => {
const historyManager = getHistoryManager();
if (sessionId) {
// Get entries for specific session
const entries = historyManager.getEntries(sessionId);
// Sort by timestamp descending
entries.sort((a, b) => b.timestamp - a.timestamp);
return entries;
}
if (projectPath) {
// Get all entries for sessions in this project
return historyManager.getEntriesByProjectPath(projectPath);
}
// Return all entries (for global view)
return historyManager.getAllEntries();
});
// Set up callback for web server to write commands to sessions
// Note: Process IDs have -ai or -terminal suffix based on session's inputMode
server.setWriteToSessionCallback((sessionId: string, data: string) => {
if (!processManager) {
logger.warn('processManager is null for writeToSession', 'WebServer');
return false;
}
// Get the session's current inputMode to determine which process to write to
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found for writeToSession`, 'WebServer');
return false;
}
// Append -ai or -terminal suffix based on inputMode
const targetSessionId = session.inputMode === 'ai' ? `${sessionId}-ai` : `${sessionId}-terminal`;
logger.debug(`Writing to ${targetSessionId} (inputMode=${session.inputMode})`, 'WebServer');
const result = processManager.write(targetSessionId, data);
logger.debug(`Write result: ${result}`, 'WebServer');
return result;
});
// Set up callback for web server to execute commands through the desktop
// This forwards AI commands to the renderer, ensuring single source of truth
// The renderer handles all spawn logic, state management, and broadcasts
server.setExecuteCommandCallback(async (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => {
if (!mainWindow) {
logger.warn('mainWindow is null for executeCommand', 'WebServer');
return false;
}
// Look up the session to get Claude session ID for logging
const sessions = sessionsStore.get('sessions', []);
const session = sessions.find((s: any) => s.id === sessionId);
const agentSessionId = session?.agentSessionId || 'none';
// Forward to renderer - it will handle spawn, state, and everything else
// This ensures web commands go through exact same code path as desktop commands
// Pass inputMode so renderer uses the web's intended mode (avoids sync issues)
logger.info(`[Web → Renderer] Forwarding command | Maestro: ${sessionId} | Claude: ${agentSessionId} | Mode: ${inputMode || 'auto'} | Command: ${command.substring(0, 100)}`, 'WebServer');
mainWindow.webContents.send('remote:executeCommand', sessionId, command, inputMode);
return true;
});
// Set up callback for web server to interrupt sessions through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setInterruptSessionCallback(async (sessionId: string) => {
if (!mainWindow) {
logger.warn('mainWindow is null for interrupt', 'WebServer');
return false;
}
// Forward to renderer - it will handle interrupt, state update, and broadcasts
// This ensures web interrupts go through exact same code path as desktop interrupts
logger.debug(`Forwarding interrupt to renderer for session ${sessionId}`, 'WebServer');
mainWindow.webContents.send('remote:interrupt', sessionId);
return true;
});
// Set up callback for web server to switch session mode through the desktop
// This forwards to the renderer which handles state updates and broadcasts
server.setSwitchModeCallback(async (sessionId: string, mode: 'ai' | 'terminal') => {
logger.info(`[Web→Desktop] Mode switch callback invoked: session=${sessionId}, mode=${mode}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for switchMode', 'WebServer');
return false;
}
// Forward to renderer - it will handle mode switch and broadcasts
// This ensures web mode switches go through exact same code path as desktop
logger.info(`[Web→Desktop] Sending IPC remote:switchMode to renderer`, 'WebServer');
mainWindow.webContents.send('remote:switchMode', sessionId, mode);
return true;
});
// Set up callback for web server to select/switch to a session in the desktop
// This forwards to the renderer which handles state updates and broadcasts
// If tabId is provided, also switches to that tab within the session
server.setSelectSessionCallback(async (sessionId: string, tabId?: string) => {
logger.info(`[Web→Desktop] Session select callback invoked: session=${sessionId}, tab=${tabId || 'none'}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for selectSession', 'WebServer');
return false;
}
// Forward to renderer - it will handle session selection and broadcasts
logger.info(`[Web→Desktop] Sending IPC remote:selectSession to renderer`, 'WebServer');
mainWindow.webContents.send('remote:selectSession', sessionId, tabId);
return true;
});
// Tab operation callbacks
server.setSelectTabCallback(async (sessionId: string, tabId: string) => {
logger.info(`[Web→Desktop] Tab select callback invoked: session=${sessionId}, tab=${tabId}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for selectTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:selectTab', sessionId, tabId);
return true;
});
server.setNewTabCallback(async (sessionId: string) => {
logger.info(`[Web→Desktop] New tab callback invoked: session=${sessionId}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for newTab', 'WebServer');
return null;
}
// Use invoke for synchronous response with tab ID
return new Promise((resolve) => {
const responseChannel = `remote:newTab:response:${Date.now()}`;
ipcMain.once(responseChannel, (_event, result) => {
resolve(result);
});
mainWindow!.webContents.send('remote:newTab', sessionId, responseChannel);
// Timeout after 5 seconds
setTimeout(() => resolve(null), 5000);
});
});
server.setCloseTabCallback(async (sessionId: string, tabId: string) => {
logger.info(`[Web→Desktop] Close tab callback invoked: session=${sessionId}, tab=${tabId}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for closeTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:closeTab', sessionId, tabId);
return true;
});
server.setRenameTabCallback(async (sessionId: string, tabId: string, newName: string) => {
logger.info(`[Web→Desktop] Rename tab callback invoked: session=${sessionId}, tab=${tabId}, newName=${newName}`, 'WebServer');
if (!mainWindow) {
logger.warn('mainWindow is null for renameTab', 'WebServer');
return false;
}
mainWindow.webContents.send('remote:renameTab', sessionId, tabId, newName);
return true;
});
return server;
}
function createWindow() {
// Restore saved window state
const savedState = windowStateStore.store;
mainWindow = new BrowserWindow({
x: savedState.x,
y: savedState.y,
width: savedState.width,
height: savedState.height,
minWidth: 1000,
minHeight: 600,
backgroundColor: '#0b0b0d',
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
// Restore maximized/fullscreen state after window is created
if (savedState.isFullScreen) {
mainWindow.setFullScreen(true);
} else if (savedState.isMaximized) {
mainWindow.maximize();
}
logger.info('Browser window created', 'Window', {
size: `${savedState.width}x${savedState.height}`,
maximized: savedState.isMaximized,
fullScreen: savedState.isFullScreen,
mode: process.env.NODE_ENV || 'production'
});
// Save window state before closing
const saveWindowState = () => {
if (!mainWindow) return;
const isMaximized = mainWindow.isMaximized();
const isFullScreen = mainWindow.isFullScreen();
const bounds = mainWindow.getBounds();
// Only save bounds if not maximized/fullscreen (to restore proper size later)
if (!isMaximized && !isFullScreen) {
windowStateStore.set('x', bounds.x);
windowStateStore.set('y', bounds.y);
windowStateStore.set('width', bounds.width);
windowStateStore.set('height', bounds.height);
}
windowStateStore.set('isMaximized', isMaximized);
windowStateStore.set('isFullScreen', isFullScreen);
};
mainWindow.on('close', saveWindowState);
// Load the app
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
// DevTools can be opened via Command-K menu instead of automatically on startup
logger.info('Loading development server', 'Window');
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
logger.info('Loading production build', 'Window');
// Open DevTools in production if DEBUG env var is set
if (process.env.DEBUG === 'true') {
mainWindow.webContents.openDevTools();
}
}
mainWindow.on('closed', () => {
logger.info('Browser window closed', 'Window');
mainWindow = null;
});
// Initialize auto-updater (only in production)
if (process.env.NODE_ENV !== 'development') {
initAutoUpdater(mainWindow);
logger.info('Auto-updater initialized', 'Window');
} else {
// Register stub handlers in development mode so users get a helpful error
ipcMain.handle('updates:download', async () => {
return { success: false, error: 'Auto-update is disabled in development mode. Please check update first.' };
});
ipcMain.handle('updates:install', async () => {
logger.warn('Auto-update install called in development mode', 'AutoUpdater');
});
ipcMain.handle('updates:getStatus', async () => {
return { status: 'idle' as const };
});
ipcMain.handle('updates:checkAutoUpdater', async () => {
return { success: false, error: 'Auto-update is disabled in development mode' };
});
logger.info('Auto-updater disabled in development mode (stub handlers registered)', 'Window');
}
}
// Set up global error handlers for uncaught exceptions
process.on('uncaughtException', (error: Error) => {
logger.error(
`Uncaught Exception: ${error.message}`,
'UncaughtException',
{
stack: error.stack,
name: error.name,
}
);
// Don't exit the process - let it continue running
});
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error(
`Unhandled Promise Rejection: ${reason?.message || String(reason)}`,
'UnhandledRejection',
{
reason: reason,
stack: reason?.stack,
promise: String(promise),
}
);
});
app.whenReady().then(async () => {
// Load logger settings first
const logLevel = store.get('logLevel', 'info');
logger.setLogLevel(logLevel);
const maxLogBuffer = store.get('maxLogBuffer', 1000);
logger.setMaxLogBuffer(maxLogBuffer);
logger.info('Maestro application starting', 'Startup', {
version: app.getVersion(),
platform: process.platform,
logLevel
});
// Initialize core services
logger.info('Initializing core services', 'Startup');
processManager = new ProcessManager();
// Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks)
agentDetector = new AgentDetector();
// Load custom agent paths from settings
const allAgentConfigs = agentConfigsStore.get('configs', {});
const customPaths: Record<string, string> = {};
for (const [agentId, config] of Object.entries(allAgentConfigs)) {
if (config && typeof config === 'object' && 'customPath' in config && config.customPath) {
customPaths[agentId] = config.customPath as string;
}
}
if (Object.keys(customPaths).length > 0) {
agentDetector.setCustomPaths(customPaths);
logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup');
}
logger.info('Core services initialized', 'Startup');
// Initialize history manager (handles migration from legacy format if needed)
logger.info('Initializing history manager', 'Startup');
const historyManager = getHistoryManager();
try {
await historyManager.initialize();
logger.info('History manager initialized', 'Startup');
// Start watching history directory for external changes (from CLI, etc.)
historyManager.startWatching((sessionId) => {
logger.debug(`History file changed for session ${sessionId}, notifying renderer`, 'HistoryWatcher');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('history:externalChange', sessionId);
}
});
} catch (error) {
// Migration failed - log error but continue with app startup
// History will be unavailable but the app will still function
logger.error(`Failed to initialize history manager: ${error}`, 'Startup');
logger.warn('Continuing without history - history features will be unavailable', 'Startup');
}
// Set up IPC handlers
logger.debug('Setting up IPC handlers', 'Startup');
setupIpcHandlers();
// Set up process event listeners
logger.debug('Setting up process event listeners', 'Startup');
setupProcessListeners();
// Create main window
logger.info('Creating main window', 'Startup');
createWindow();
// Note: History file watching is handled by HistoryManager.startWatching() above
// which uses the new per-session file format in the history/ directory
// Start CLI activity watcher (polls every 2 seconds for CLI playbook runs)
startCliActivityWatcher();
// Note: Web server is not auto-started - it starts when user enables web interface
// via live:startServer IPC call from the renderer
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async () => {
logger.info('Application shutting down', 'Shutdown');
// Stop history manager watcher
getHistoryManager().stopWatching();
// Stop CLI activity watcher
if (cliActivityWatcher) {
cliActivityWatcher.close();
cliActivityWatcher = null;
}
// Clean up all running processes
logger.info('Killing all running processes', 'Shutdown');
processManager?.killAll();
logger.info('Stopping tunnel', 'Shutdown');
await tunnelManager.stop();
logger.info('Stopping web server', 'Shutdown');
await webServer?.stop();
logger.info('Shutdown complete', 'Shutdown');
});
/**
* Start CLI activity file watcher
* Uses fs.watch() for event-driven detection when CLI is running playbooks
*/
function startCliActivityWatcher() {
const cliActivityPath = path.join(app.getPath('userData'), 'cli-activity.json');
const cliActivityDir = path.dirname(cliActivityPath);
// Ensure directory exists for watching
if (!fsSync.existsSync(cliActivityDir)) {
fsSync.mkdirSync(cliActivityDir, { recursive: true });
}
// Watch the directory for file changes (handles file creation/deletion)
// Using directory watch because fs.watch on non-existent file throws
try {
cliActivityWatcher = fsSync.watch(cliActivityDir, (_eventType, filename) => {
if (filename === 'cli-activity.json') {
logger.debug('CLI activity file changed, notifying renderer', 'CliActivityWatcher');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('cli:activityChange');
}
}
});
cliActivityWatcher.on('error', (error) => {
logger.error(`CLI activity watcher error: ${error.message}`, 'CliActivityWatcher');
});
logger.info('CLI activity watcher started', 'Startup');
} catch (error) {
logger.error(`Failed to start CLI activity watcher: ${error}`, 'CliActivityWatcher');
}
}
function setupIpcHandlers() {
// Settings, sessions, and groups persistence - extracted to src/main/ipc/handlers/persistence.ts
// Broadcast user input to web clients (called when desktop sends a message)
ipcMain.handle('web:broadcastUserInput', async (_, sessionId: string, command: string, inputMode: 'ai' | 'terminal') => {
if (webServer && webServer.getWebClientCount() > 0) {
webServer.broadcastUserInput(sessionId, command, inputMode);
return true;
}
return false;
});
// Broadcast AutoRun state to web clients (called when batch processing state changes)
// Always store state even if no clients are connected, so new clients get initial state
ipcMain.handle('web:broadcastAutoRunState', async (_, sessionId: string, state: {
isRunning: boolean;
totalTasks: number;
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
} | null) => {
if (webServer) {
// Always call broadcastAutoRunState - it stores the state for new clients
// and broadcasts to any currently connected clients
webServer.broadcastAutoRunState(sessionId, state);
return true;
}
return false;
});
// Broadcast tab changes to web clients
ipcMain.handle('web:broadcastTabsChange', async (_, sessionId: string, aiTabs: any[], activeTabId: string) => {
if (webServer && webServer.getWebClientCount() > 0) {
webServer.broadcastTabsChange(sessionId, aiTabs, activeTabId);
return true;
}
return false;
});
// Git operations - extracted to src/main/ipc/handlers/git.ts
registerGitHandlers();
// Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts
registerAutorunHandlers({
mainWindow,
getMainWindow: () => mainWindow,
app,
});
// Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts
registerPlaybooksHandlers({
mainWindow,
getMainWindow: () => mainWindow,
app,
});
// History operations - extracted to src/main/ipc/handlers/history.ts
// Uses HistoryManager singleton for per-session storage
registerHistoryHandlers();
// Agent management operations - extracted to src/main/ipc/handlers/agents.ts
registerAgentsHandlers({
getAgentDetector: () => agentDetector,
agentConfigsStore,
});
// Process management operations - extracted to src/main/ipc/handlers/process.ts
registerProcessHandlers({
getProcessManager: () => processManager,
getAgentDetector: () => agentDetector,
agentConfigsStore,
settingsStore: store,
});
// Persistence operations - extracted to src/main/ipc/handlers/persistence.ts
registerPersistenceHandlers({
settingsStore: store,
sessionsStore,
groupsStore,
getWebServer: () => webServer,
});
// System operations - extracted to src/main/ipc/handlers/system.ts
registerSystemHandlers({
getMainWindow: () => mainWindow,
app,
settingsStore: store,
tunnelManager,
getWebServer: () => webServer,
bootstrapStore, // For iCloud/sync settings
});
// Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts
registerClaudeHandlers({
claudeSessionOriginsStore,
getMainWindow: () => mainWindow,
});
// Initialize output parsers for all agents (Codex, OpenCode, Claude Code)
// This must be called before any agent output is processed
initializeOutputParsers();
// Initialize session storages and register generic agent sessions handlers
// This provides the new window.maestro.agentSessions.* API
// Pass the shared claudeSessionOriginsStore so session names/stars are consistent
initializeSessionStorages({ claudeSessionOriginsStore });
registerAgentSessionsHandlers({ getMainWindow: () => mainWindow, agentSessionOriginsStore });
// Helper to get agent config values (custom args/env vars, model, etc.)
const getAgentConfigForAgent = (agentId: string): Record<string, any> => {
const allConfigs = agentConfigsStore.get('configs', {});
return allConfigs[agentId] || {};
};
// Helper to get custom env vars for an agent
const getCustomEnvVarsForAgent = (agentId: string): Record<string, string> | undefined => {
return getAgentConfigForAgent(agentId).customEnvVars as Record<string, string> | undefined;
};
// Register Group Chat handlers
registerGroupChatHandlers({
getMainWindow: () => mainWindow,
getProcessManager: () => processManager,
getAgentDetector: () => agentDetector,
getCustomEnvVars: getCustomEnvVarsForAgent,
getAgentConfig: getAgentConfigForAgent,
});
// Register Debug Package handlers
registerDebugHandlers({
getMainWindow: () => mainWindow,
getAgentDetector: () => agentDetector,
getProcessManager: () => processManager,
getWebServer: () => webServer,
settingsStore: store,
sessionsStore,
groupsStore,
bootstrapStore,
});
// Set up callback for group chat router to lookup sessions for auto-add @mentions
setGetSessionsCallback(() => {
const sessions = sessionsStore.get('sessions', []);
return sessions.map((s: any) => ({
id: s.id,
name: s.name,
toolType: s.toolType,
cwd: s.cwd || s.fullPath || process.env.HOME || '/tmp',
customArgs: s.customArgs,
customEnvVars: s.customEnvVars,
customModel: s.customModel,
}));
});
// Set up callback for group chat router to lookup custom env vars for agents
setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent);
setGetAgentConfigCallback(getAgentConfigForAgent);
// Setup logger event forwarding to renderer
setupLoggerEventForwarding(() => mainWindow);
// File system operations
ipcMain.handle('fs:homeDir', () => {
return os.homedir();
});
ipcMain.handle('fs:readDir', async (_, dirPath: string) => {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
// Convert Dirent objects to plain objects for IPC serialization
return entries.map((entry: any) => ({
name: entry.name,
isDirectory: entry.isDirectory(),
isFile: entry.isFile()
}));
});
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
try {
// Check if file is an image
const ext = filePath.split('.').pop()?.toLowerCase();
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
const isImage = imageExtensions.includes(ext || '');
if (isImage) {
// Read image as buffer and convert to base64 data URL
const buffer = await fs.readFile(filePath);
const base64 = buffer.toString('base64');
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
return `data:${mimeType};base64,${base64}`;
} else {
// Read text files as UTF-8
const content = await fs.readFile(filePath, 'utf-8');
return content;
}
} catch (error) {
throw new Error(`Failed to read file: ${error}`);
}
});
ipcMain.handle('fs:stat', async (_, filePath: string) => {
try {
const stats = await fs.stat(filePath);
return {
size: stats.size,
createdAt: stats.birthtime.toISOString(),
modifiedAt: stats.mtime.toISOString(),
isDirectory: stats.isDirectory(),
isFile: stats.isFile()
};
} catch (error) {
throw new Error(`Failed to get file stats: ${error}`);
}
});
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
try {
await fs.writeFile(filePath, content, 'utf-8');
return { success: true };
} catch (error) {
throw new Error(`Failed to write file: ${error}`);
}
});
// Live session management - toggle sessions as live/offline in web interface
ipcMain.handle('live:toggle', async (_, sessionId: string, agentSessionId?: string) => {
if (!webServer) {
throw new Error('Web server not initialized');
}
// Ensure web server is running before allowing live toggle
if (!webServer.isActive()) {
logger.warn('Web server not yet started, waiting...', 'Live');
// Wait for server to start (with timeout)
const startTime = Date.now();
while (!webServer.isActive() && Date.now() - startTime < 5000) {
await new Promise(resolve => setTimeout(resolve, 100));
}
if (!webServer.isActive()) {
throw new Error('Web server failed to start');
}
}
const isLive = webServer.isSessionLive(sessionId);
if (isLive) {
// Turn off live mode
webServer.setSessionOffline(sessionId);
logger.info(`Session ${sessionId} is now offline`, 'Live');
return { live: false, url: null };
} else {
// Turn on live mode
logger.info(`Enabling live mode for session ${sessionId} (claude: ${agentSessionId || 'none'})`, 'Live');
webServer.setSessionLive(sessionId, agentSessionId);
const url = webServer.getSessionUrl(sessionId);
logger.info(`Session ${sessionId} is now live at ${url}`, 'Live');
return { live: true, url };
}
});
ipcMain.handle('live:getStatus', async (_, sessionId: string) => {
if (!webServer) {
return { live: false, url: null };
}
const isLive = webServer.isSessionLive(sessionId);
return {
live: isLive,
url: isLive ? webServer.getSessionUrl(sessionId) : null,
};
});
ipcMain.handle('live:getDashboardUrl', async () => {
if (!webServer) {
return null;
}
return webServer.getSecureUrl();
});
ipcMain.handle('live:getLiveSessions', async () => {
if (!webServer) {
return [];
}
return webServer.getLiveSessions();
});
ipcMain.handle('live:broadcastActiveSession', async (_, sessionId: string) => {
if (webServer) {
webServer.broadcastActiveSessionChange(sessionId);
}
});
// Start web server (creates if needed, starts if not running)
ipcMain.handle('live:startServer', async () => {
try {
// Create web server if it doesn't exist
if (!webServer) {
logger.info('Creating web server', 'WebServer');
webServer = createWebServer();
}
// Start if not already running
if (!webServer.isActive()) {
logger.info('Starting web server', 'WebServer');
const { port, url } = await webServer.start();
logger.info(`Web server running at ${url} (port ${port})`, 'WebServer');
return { success: true, url };
}
// Already running
return { success: true, url: webServer.getSecureUrl() };
} catch (error: any) {
logger.error(`Failed to start web server: ${error.message}`, 'WebServer');
return { success: false, error: error.message };
}
});
// Stop web server and clean up
ipcMain.handle('live:stopServer', async () => {
if (!webServer) {
return { success: true };
}
try {
logger.info('Stopping web server', 'WebServer');
await webServer.stop();
webServer = null; // Allow garbage collection, will recreate on next start
logger.info('Web server stopped and cleaned up', 'WebServer');
return { success: true };
} catch (error: any) {
logger.error(`Failed to stop web server: ${error.message}`, 'WebServer');
return { success: false, error: error.message };
}
});
// Disable all live sessions and stop the server
ipcMain.handle('live:disableAll', async () => {
if (!webServer) {
return { success: true, count: 0 };
}
// First mark all sessions as offline
const liveSessions = webServer.getLiveSessions();
const count = liveSessions.length;
for (const session of liveSessions) {
webServer.setSessionOffline(session.sessionId);
}
// Then stop the server
try {
logger.info(`Disabled ${count} live sessions, stopping server`, 'Live');
await webServer.stop();
webServer = null;
return { success: true, count };
} catch (error: any) {
logger.error(`Failed to stop web server during disableAll: ${error.message}`, 'WebServer');
return { success: false, count, error: error.message };
}
});
// Web server management
ipcMain.handle('webserver:getUrl', async () => {
return webServer?.getSecureUrl();
});
ipcMain.handle('webserver:getConnectedClients', async () => {
return webServer?.getWebClientCount() || 0;
});
// System operations (dialog, fonts, shells, tunnel, devtools, updates, logger)
// extracted to src/main/ipc/handlers/system.ts
// Claude Code sessions - extracted to src/main/ipc/handlers/claude.ts
// ==========================================================================
// Agent Error Handling API
// ==========================================================================
// Clear an error state for a session (called after recovery action)
ipcMain.handle('agent:clearError', async (_event, sessionId: string) => {
logger.debug('Clearing agent error for session', 'AgentError', { sessionId });
// Note: The actual error state is managed in the renderer.
// This handler is used to log the clear action and potentially
// perform any main process cleanup needed.
return { success: true };
});
// Retry the last operation after an error (optionally with modified parameters)
ipcMain.handle('agent:retryAfterError', async (_event, sessionId: string, options?: {
prompt?: string;
newSession?: boolean;
}) => {
logger.info('Retrying after agent error', 'AgentError', {
sessionId,
hasPrompt: !!options?.prompt,
newSession: options?.newSession || false,
});
// Note: The actual retry logic is handled in the renderer, which will:
// 1. Clear the error state
// 2. Optionally start a new session
// 3. Re-send the last command or the provided prompt
// This handler exists for logging and potential future main process coordination.
return { success: true };
});
// Notification operations
ipcMain.handle('notification:show', async (_event, title: string, body: string) => {
try {
const { Notification } = await import('electron');
if (Notification.isSupported()) {
const notification = new Notification({
title,
body,
silent: true, // Don't play system sound - we have our own audio feedback option
});
notification.show();
logger.debug('Showed OS notification', 'Notification', { title, body });
return { success: true };
} else {
logger.warn('OS notifications not supported on this platform', 'Notification');
return { success: false, error: 'Notifications not supported' };
}
} catch (error) {
logger.error('Error showing notification', 'Notification', error);
return { success: false, error: String(error) };
}
});
// Track active TTS processes by ID for stopping
const activeTtsProcesses = new Map<number, { process: ReturnType<typeof import('child_process').spawn>; command: string }>();
let ttsProcessIdCounter = 0;
// Audio feedback using system TTS command - pipes text via stdin
ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => {
console.log('[TTS Main] notification:speak called, text length:', text?.length, 'command:', command);
// Log the incoming request with full details for debugging
logger.info('TTS speak request received', 'TTS', {
command: command || '(default: say)',
textLength: text?.length || 0,
textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)',
});
try {
const { spawn } = await import('child_process');
const fullCommand = command || 'say'; // Default to macOS 'say' command
console.log('[TTS Main] Using fullCommand:', fullCommand);
console.log('[TTS Main] Spawning with shell:', fullCommand);
// Log the full command being executed
logger.info('TTS executing command', 'TTS', {
command: fullCommand,
textLength: text?.length || 0,
});
// Spawn the TTS process with shell mode to support pipes and command chaining
const child = spawn(fullCommand, [], {
stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors
shell: true,
});
// Generate a unique ID for this TTS process
const ttsId = ++ttsProcessIdCounter;
activeTtsProcesses.set(ttsId, { process: child, command: fullCommand });
// Write the text to stdin and close it
if (child.stdin) {
// Handle stdin errors (EPIPE if process terminates before write completes)
child.stdin.on('error', (err) => {
const errorCode = (err as NodeJS.ErrnoException).code;
if (errorCode === 'EPIPE') {
logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS');
} else {
logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode });
}
});
child.stdin.write(text);
child.stdin.end();
}
child.on('error', (err) => {
console.error('[TTS Main] Spawn error:', err);
logger.error('TTS spawn error', 'TTS', {
error: String(err),
command: fullCommand,
textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)',
});
activeTtsProcesses.delete(ttsId);
});
// Capture stderr for debugging
let stderrOutput = '';
if (child.stderr) {
child.stderr.on('data', (data) => {
stderrOutput += data.toString();
});
}
child.on('close', (code) => {
console.log('[TTS Main] Process exited with code:', code);
if (code !== 0 && stderrOutput) {
console.error('[TTS Main] stderr:', stderrOutput);
logger.error('TTS process error output', 'TTS', {
exitCode: code,
stderr: stderrOutput,
command: fullCommand,
});
}
activeTtsProcesses.delete(ttsId);
// Notify renderer that TTS has completed
BrowserWindow.getAllWindows().forEach((win) => {
win.webContents.send('tts:completed', ttsId);
});
});
console.log('[TTS Main] Process spawned successfully with ID:', ttsId);
logger.info('TTS process spawned successfully', 'TTS', {
ttsId,
command: fullCommand,
textLength: text?.length || 0,
});
return { success: true, ttsId };
} catch (error) {
console.error('[TTS Main] Error starting audio feedback:', error);
logger.error('TTS error starting audio feedback', 'TTS', {
error: String(error),
command: command || '(default: say)',
textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)',
});
return { success: false, error: String(error) };
}
});
// Stop a running TTS process
ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number) => {
console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId);
const ttsProcess = activeTtsProcesses.get(ttsId);
if (!ttsProcess) {
console.log('[TTS Main] No active TTS process found with ID:', ttsId);
return { success: false, error: 'No active TTS process with that ID' };
}
try {
// Kill the process and all its children
ttsProcess.process.kill('SIGTERM');
activeTtsProcesses.delete(ttsId);
logger.info('TTS process stopped', 'TTS', {
ttsId,
command: ttsProcess.command,
});
console.log('[TTS Main] TTS process killed successfully');
return { success: true };
} catch (error) {
console.error('[TTS Main] Error stopping TTS process:', error);
logger.error('TTS error stopping process', 'TTS', {
ttsId,
error: String(error),
});
return { success: false, error: String(error) };
}
});
// Attachments API - store images per Maestro session
// Images are stored in userData/attachments/{sessionId}/{filename}
ipcMain.handle('attachments:save', async (_event, sessionId: string, base64Data: string, filename: string) => {
try {
const userDataPath = app.getPath('userData');
const attachmentsDir = path.join(userDataPath, 'attachments', sessionId);
// Ensure the attachments directory exists
await fs.mkdir(attachmentsDir, { recursive: true });
// Extract the base64 content (remove data:image/...;base64, prefix if present)
const base64Match = base64Data.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/);
let buffer: Buffer;
let finalFilename = filename;
if (base64Match) {
const extension = base64Match[1];
buffer = Buffer.from(base64Match[2], 'base64');
// Update filename with correct extension if not already present
if (!filename.includes('.')) {
finalFilename = `${filename}.${extension}`;
}
} else {
// Assume raw base64
buffer = Buffer.from(base64Data, 'base64');
}
const filePath = path.join(attachmentsDir, finalFilename);
await fs.writeFile(filePath, buffer);
logger.info(`Saved attachment: ${filePath}`, 'Attachments', { sessionId, filename: finalFilename, size: buffer.length });
return { success: true, path: filePath, filename: finalFilename };
} catch (error) {
logger.error('Error saving attachment', 'Attachments', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle('attachments:load', async (_event, sessionId: string, filename: string) => {
try {
const userDataPath = app.getPath('userData');
const filePath = path.join(userDataPath, 'attachments', sessionId, filename);
const buffer = await fs.readFile(filePath);
const base64 = buffer.toString('base64');
// Determine MIME type from extension
const ext = path.extname(filename).toLowerCase().slice(1);
const mimeTypes: Record<string, string> = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
};
const mimeType = mimeTypes[ext] || 'image/png';
logger.debug(`Loaded attachment: ${filePath}`, 'Attachments', { sessionId, filename, size: buffer.length });
return { success: true, dataUrl: `data:${mimeType};base64,${base64}` };
} catch (error) {
logger.error('Error loading attachment', 'Attachments', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle('attachments:delete', async (_event, sessionId: string, filename: string) => {
try {
const userDataPath = app.getPath('userData');
const filePath = path.join(userDataPath, 'attachments', sessionId, filename);
await fs.unlink(filePath);
logger.info(`Deleted attachment: ${filePath}`, 'Attachments', { sessionId, filename });
return { success: true };
} catch (error) {
logger.error('Error deleting attachment', 'Attachments', error);
return { success: false, error: String(error) };
}
});
ipcMain.handle('attachments:list', async (_event, sessionId: string) => {
try {
const userDataPath = app.getPath('userData');
const attachmentsDir = path.join(userDataPath, 'attachments', sessionId);
try {
const files = await fs.readdir(attachmentsDir);
const imageFiles = files.filter(f => /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(f));
logger.debug(`Listed attachments for session: ${sessionId}`, 'Attachments', { count: imageFiles.length });
return { success: true, files: imageFiles };
} catch (err: any) {
if (err.code === 'ENOENT') {
// Directory doesn't exist yet - no attachments
return { success: true, files: [] };
}
throw err;
}
} catch (error) {
logger.error('Error listing attachments', 'Attachments', error);
return { success: false, error: String(error), files: [] };
}
});
ipcMain.handle('attachments:getPath', async (_event, sessionId: string) => {
const userDataPath = app.getPath('userData');
const attachmentsDir = path.join(userDataPath, 'attachments', sessionId);
return { success: true, path: attachmentsDir };
});
// Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts
// Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts
// ==========================================================================
// Leaderboard API
// ==========================================================================
// Submit leaderboard entry to runmaestro.ai
ipcMain.handle(
'leaderboard:submit',
async (
_event,
data: {
email: string;
displayName: string;
githubUsername?: string;
twitterHandle?: string;
linkedinHandle?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
longestRunMs?: number;
longestRunDate?: string;
currentRunMs?: number; // Duration in milliseconds of the run that just completed
theme?: string;
clientToken?: string; // Client-generated token for polling auth status
authToken?: string; // Required for confirmed email addresses
}
): Promise<{
success: boolean;
message: string;
pendingEmailConfirmation?: boolean;
error?: string;
authTokenRequired?: boolean; // True if 401 due to missing token
ranking?: {
cumulative: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
};
longestRun: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
} | null;
};
}> => {
try {
logger.info('Submitting leaderboard entry', 'Leaderboard', {
displayName: data.displayName,
email: data.email.substring(0, 3) + '***',
badgeLevel: data.badgeLevel,
hasClientToken: !!data.clientToken,
hasAuthToken: !!data.authToken,
});
const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': `Maestro/${app.getVersion()}`,
},
body: JSON.stringify(data),
});
const result = await response.json() as {
success?: boolean;
message?: string;
pendingEmailConfirmation?: boolean;
error?: string;
ranking?: {
cumulative: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
};
longestRun: {
rank: number;
total: number;
previousRank: number | null;
improved: boolean;
} | null;
};
};
if (response.ok) {
logger.info('Leaderboard submission successful', 'Leaderboard', {
pendingEmailConfirmation: result.pendingEmailConfirmation,
ranking: result.ranking,
});
return {
success: true,
message: result.message || 'Submission received',
pendingEmailConfirmation: result.pendingEmailConfirmation,
ranking: result.ranking,
};
} else if (response.status === 401) {
// Auth token required or invalid
logger.warn('Leaderboard submission requires auth token', 'Leaderboard', {
error: result.error || result.message,
});
return {
success: false,
message: result.message || 'Authentication required',
error: result.error || 'Auth token required for confirmed email addresses',
authTokenRequired: true,
};
} else {
logger.warn('Leaderboard submission failed', 'Leaderboard', {
status: response.status,
error: result.error || result.message,
});
return {
success: false,
message: result.message || 'Submission failed',
error: result.error || `Server error: ${response.status}`,
};
}
} catch (error) {
logger.error('Error submitting to leaderboard', 'Leaderboard', error);
return {
success: false,
message: 'Failed to connect to leaderboard server',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
);
// Poll for auth token after email confirmation
ipcMain.handle(
'leaderboard:pollAuthStatus',
async (
_event,
clientToken: string
): Promise<{
status: 'pending' | 'confirmed' | 'expired' | 'error';
authToken?: string;
message?: string;
error?: string;
}> => {
try {
logger.debug('Polling leaderboard auth status', 'Leaderboard');
const response = await fetch(
`https://runmaestro.ai/api/m4estr0/auth-status?clientToken=${encodeURIComponent(clientToken)}`,
{
headers: {
'User-Agent': `Maestro/${app.getVersion()}`,
},
}
);
const result = await response.json() as {
status: 'pending' | 'confirmed' | 'expired';
authToken?: string;
message?: string;
};
if (response.ok) {
if (result.status === 'confirmed' && result.authToken) {
logger.info('Leaderboard auth token received', 'Leaderboard');
}
return {
status: result.status,
authToken: result.authToken,
message: result.message,
};
} else {
return {
status: 'error',
error: result.message || `Server error: ${response.status}`,
};
}
} catch (error) {
logger.error('Error polling leaderboard auth status', 'Leaderboard', error);
return {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
);
// Get leaderboard entries
ipcMain.handle(
'leaderboard:get',
async (
_event,
options?: { limit?: number }
): Promise<{
success: boolean;
entries?: Array<{
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
}>;
error?: string;
}> => {
try {
const limit = options?.limit || 50;
const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, {
headers: {
'User-Agent': `Maestro/${app.getVersion()}`,
},
});
if (response.ok) {
const data = await response.json() as { entries?: unknown[] };
return { success: true, entries: data.entries as Array<{
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
}> };
} else {
return {
success: false,
error: `Server error: ${response.status}`,
};
}
} catch (error) {
logger.error('Error fetching leaderboard', 'Leaderboard', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
);
// Get longest runs leaderboard
ipcMain.handle(
'leaderboard:getLongestRuns',
async (
_event,
options?: { limit?: number }
): Promise<{
success: boolean;
entries?: Array<{
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
longestRunMs: number;
runDate: string;
}>;
error?: string;
}> => {
try {
const limit = options?.limit || 50;
const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, {
headers: {
'User-Agent': `Maestro/${app.getVersion()}`,
},
});
if (response.ok) {
const data = await response.json() as { entries?: unknown[] };
return { success: true, entries: data.entries as Array<{
rank: number;
displayName: string;
githubUsername?: string;
avatarUrl?: string;
longestRunMs: number;
runDate: string;
}> };
} else {
return {
success: false,
error: `Server error: ${response.status}`,
};
}
} catch (error) {
logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
);
}
// Buffer for group chat output (keyed by sessionId)
// We buffer output and only route it on process exit to avoid duplicate messages from streaming chunks
const groupChatOutputBuffers = new Map<string, string>();
/**
* Extract text content from agent JSON output format.
* Uses the registered output parser for the given agent type.
* Different agents have different output formats:
* - Claude: { type: 'result', result: '...' } and { type: 'assistant', message: { content: ... } }
* - OpenCode: { type: 'text', part: { text: '...' } } and { type: 'step_finish', part: { reason: 'stop' } }
*
* @param rawOutput - The raw JSONL output from the agent
* @param agentType - The agent type (e.g., 'claude-code', 'opencode')
* @returns Extracted text content
*/
function extractTextFromAgentOutput(rawOutput: string, agentType: string): string {
const parser = getOutputParser(agentType);
// If no parser found, try a generic extraction
if (!parser) {
logger.warn(`No parser found for agent type '${agentType}', using generic extraction`, '[GroupChat]');
return extractTextGeneric(rawOutput);
}
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text from process-manager)
const firstNonEmptyLine = lines.find(line => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
logger.debug(`[GroupChat] Input is not JSONL, returning as plain text (len=${rawOutput.length})`, '[GroupChat]');
return rawOutput;
}
const textParts: string[] = [];
let resultText: string | null = null;
let resultMessageCount = 0;
let textMessageCount = 0;
for (const line of lines) {
if (!line.trim()) continue;
const event = parser.parseJsonLine(line);
if (!event) continue;
// Extract text based on event type
if (event.type === 'result' && event.text) {
// Result message is the authoritative final response - save it
resultText = event.text;
resultMessageCount++;
}
if (event.type === 'text' && event.text) {
textParts.push(event.text);
textMessageCount++;
}
}
// Prefer result message if available (it contains the complete formatted response)
if (resultText) {
return resultText;
}
// Fallback: if no result message, concatenate streaming text parts with newlines
// to preserve paragraph structure from partial streaming events
return textParts.join('\n');
}
/**
* Extract text content from stream-json output (JSONL).
* Uses the agent-specific parser when the agent type is known.
*/
function extractTextFromStreamJson(rawOutput: string, agentType?: string): string {
if (agentType) {
return extractTextFromAgentOutput(rawOutput, agentType);
}
return extractTextGeneric(rawOutput);
}
/**
* Generic text extraction fallback for unknown agent types.
* Tries common patterns for JSON output.
*/
function extractTextGeneric(rawOutput: string): string {
const lines = rawOutput.split('\n');
// Check if this looks like JSONL output (first non-empty line starts with '{')
// If not JSONL, return the raw output as-is (it's already parsed text)
const firstNonEmptyLine = lines.find(line => line.trim());
if (firstNonEmptyLine && !firstNonEmptyLine.trim().startsWith('{')) {
return rawOutput;
}
const textParts: string[] = [];
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
// Try common patterns
if (msg.result) return msg.result;
if (msg.text) textParts.push(msg.text);
if (msg.part?.text) textParts.push(msg.part.text);
if (msg.message?.content) {
const content = msg.message.content;
if (typeof content === 'string') {
textParts.push(content);
}
}
} catch {
// Not valid JSON - include raw text if it looks like content
if (!line.startsWith('{') && !line.includes('session_id') && !line.includes('sessionID')) {
textParts.push(line);
}
}
}
// Join with newlines to preserve paragraph structure
return textParts.join('\n');
}
/**
* Parses a group chat participant session ID to extract groupChatId and participantName.
* Handles hyphenated participant names by matching against UUID or timestamp suffixes.
*
* Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
* Examples:
* - group-chat-abc123-participant-Claude-1702934567890
* - group-chat-abc123-participant-OpenCode-Ollama-550e8400-e29b-41d4-a716-446655440000
*
* @returns null if not a participant session ID, otherwise { groupChatId, participantName }
*/
function parseParticipantSessionId(sessionId: string): { groupChatId: string; participantName: string } | null {
// First check if this is a participant session ID at all
if (!sessionId.includes('-participant-')) {
return null;
}
// Try matching with UUID suffix first (36 chars: 8-4-4-4-12 format)
// UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
const uuidMatch = sessionId.match(/^group-chat-(.+)-participant-(.+)-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i);
if (uuidMatch) {
return { groupChatId: uuidMatch[1], participantName: uuidMatch[2] };
}
// Try matching with timestamp suffix (13 digits)
const timestampMatch = sessionId.match(/^group-chat-(.+)-participant-(.+)-(\d{13,})$/);
if (timestampMatch) {
return { groupChatId: timestampMatch[1], participantName: timestampMatch[2] };
}
// Fallback: try the old pattern for backwards compatibility (non-hyphenated names)
const fallbackMatch = sessionId.match(/^group-chat-(.+)-participant-([^-]+)-/);
if (fallbackMatch) {
return { groupChatId: fallbackMatch[1], participantName: fallbackMatch[2] };
}
return null;
}
// Handle process output streaming (set up after initialization)
function setupProcessListeners() {
if (processManager) {
processManager.on('data', (sessionId: string, data: string) => {
// Handle group chat moderator output - buffer it
// Session ID format: group-chat-{groupChatId}-moderator-{uuid} or group-chat-{groupChatId}-moderator-synthesis-{uuid}
const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/);
if (moderatorMatch) {
const groupChatId = moderatorMatch[1];
console.log(`[GroupChat:Debug] MODERATOR DATA received for chat ${groupChatId}`);
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
console.log(`[GroupChat:Debug] Data length: ${data.length}`);
// Buffer the output - will be routed on process exit
const existing = groupChatOutputBuffers.get(sessionId) || '';
groupChatOutputBuffers.set(sessionId, existing + data);
console.log(`[GroupChat:Debug] Buffered total: ${(existing + data).length} chars`);
return; // Don't send to regular process:data handler
}
// Handle group chat participant output - buffer it
// Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
const participantInfo = parseParticipantSessionId(sessionId);
if (participantInfo) {
console.log(`[GroupChat:Debug] PARTICIPANT DATA received`);
console.log(`[GroupChat:Debug] Chat: ${participantInfo.groupChatId}, Participant: ${participantInfo.participantName}`);
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
console.log(`[GroupChat:Debug] Data length: ${data.length}`);
// Buffer the output - will be routed on process exit
const existing = groupChatOutputBuffers.get(sessionId) || '';
groupChatOutputBuffers.set(sessionId, existing + data);
console.log(`[GroupChat:Debug] Buffered total: ${(existing + data).length} chars`);
return; // Don't send to regular process:data handler
}
mainWindow?.webContents.send('process:data', sessionId, data);
// Broadcast to web clients - extract base session ID (remove -ai or -terminal suffix)
// IMPORTANT: Skip PTY terminal output (-terminal suffix) as it contains raw ANSI codes.
// Web interface terminal commands use runCommand() which emits with plain session IDs.
if (webServer) {
// Don't broadcast raw PTY terminal output to web clients
if (sessionId.endsWith('-terminal')) {
console.log(`[WebBroadcast] SKIPPING PTY terminal output for web: session=${sessionId}`);
return;
}
// Don't broadcast background batch/synopsis output to web clients
// These are internal Auto Run operations that should only appear in history, not as chat messages
if (sessionId.includes('-batch-') || sessionId.includes('-synopsis-')) {
console.log(`[WebBroadcast] SKIPPING batch/synopsis output for web: session=${sessionId}`);
return;
}
// Extract base session ID and tab ID from format: {id}-ai-{tabId}
const baseSessionId = sessionId.replace(/-ai-[^-]+$/, '');
const isAiOutput = sessionId.includes('-ai-');
// Extract tab ID from session ID format: {id}-ai-{tabId}
const tabIdMatch = sessionId.match(/-ai-([^-]+)$/);
const tabId = tabIdMatch ? tabIdMatch[1] : undefined;
const msgId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
console.log(`[WebBroadcast] Broadcasting session_output: msgId=${msgId}, session=${baseSessionId}, tabId=${tabId || 'none'}, source=${isAiOutput ? 'ai' : 'terminal'}, dataLen=${data.length}`);
webServer.broadcastToSessionClients(baseSessionId, {
type: 'session_output',
sessionId: baseSessionId,
tabId,
data,
source: isAiOutput ? 'ai' : 'terminal',
timestamp: Date.now(),
msgId,
});
}
});
processManager.on('exit', (sessionId: string, code: number) => {
// Handle group chat moderator exit - route buffered output and set state back to idle
// Session ID format: group-chat-{groupChatId}-moderator-{uuid}
// This handles BOTH initial moderator responses AND synthesis responses.
// The routeModeratorResponse function will check for @mentions:
// - If @mentions present: route to agents (continue conversation)
// - If no @mentions: final response to user (conversation complete for this turn)
const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/);
if (moderatorMatch) {
const groupChatId = moderatorMatch[1];
console.log(`[GroupChat:Debug] ========== MODERATOR PROCESS EXIT ==========`);
console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`);
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
console.log(`[GroupChat:Debug] Exit code: ${code}`);
logger.debug(`[GroupChat] Moderator exit: groupChatId=${groupChatId}`, 'ProcessListener', { sessionId });
// Route the buffered output now that process is complete
const bufferedOutput = groupChatOutputBuffers.get(sessionId);
console.log(`[GroupChat:Debug] Buffered output length: ${bufferedOutput?.length ?? 0}`);
if (bufferedOutput) {
console.log(`[GroupChat:Debug] Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"`);
logger.debug(`[GroupChat] Moderator has buffered output (${bufferedOutput.length} chars)`, 'ProcessListener', { groupChatId });
void (async () => {
try {
const chat = await loadGroupChat(groupChatId);
console.log(`[GroupChat:Debug] Chat loaded for parsing: ${chat?.name || 'null'}`);
const agentType = chat?.moderatorAgentId;
console.log(`[GroupChat:Debug] Agent type for parsing: ${agentType}`);
const parsedText = extractTextFromStreamJson(bufferedOutput, agentType);
console.log(`[GroupChat:Debug] Parsed text length: ${parsedText.length}`);
console.log(`[GroupChat:Debug] Parsed text preview: "${parsedText.substring(0, 300)}${parsedText.length > 300 ? '...' : ''}"`);
if (parsedText.trim()) {
console.log(`[GroupChat:Debug] Routing moderator response...`);
logger.info(`[GroupChat] Routing moderator response (${parsedText.length} chars)`, 'ProcessListener', { groupChatId });
const readOnly = getGroupChatReadOnlyState(groupChatId);
console.log(`[GroupChat:Debug] Read-only state: ${readOnly}`);
routeModeratorResponse(groupChatId, parsedText, processManager ?? undefined, agentDetector ?? undefined, readOnly).catch(err => {
console.error(`[GroupChat:Debug] ERROR routing moderator response:`, err);
logger.error('[GroupChat] Failed to route moderator response', 'ProcessListener', { error: String(err) });
});
} else {
console.log(`[GroupChat:Debug] WARNING: Parsed text is empty!`);
logger.warn('[GroupChat] Moderator output parsed to empty string', 'ProcessListener', { groupChatId, bufferedLength: bufferedOutput.length });
}
} catch (err) {
console.error(`[GroupChat:Debug] ERROR loading chat:`, err);
logger.error('[GroupChat] Failed to load chat for moderator output parsing', 'ProcessListener', { error: String(err) });
const parsedText = extractTextFromStreamJson(bufferedOutput);
if (parsedText.trim()) {
const readOnly = getGroupChatReadOnlyState(groupChatId);
routeModeratorResponse(groupChatId, parsedText, processManager ?? undefined, agentDetector ?? undefined, readOnly).catch(routeErr => {
console.error(`[GroupChat:Debug] ERROR routing moderator response (fallback):`, routeErr);
logger.error('[GroupChat] Failed to route moderator response', 'ProcessListener', { error: String(routeErr) });
});
}
}
})().finally(() => {
groupChatOutputBuffers.delete(sessionId);
console.log(`[GroupChat:Debug] Cleared output buffer for session`);
});
} else {
console.log(`[GroupChat:Debug] WARNING: No buffered output!`);
logger.warn('[GroupChat] Moderator exit with no buffered output', 'ProcessListener', { groupChatId, sessionId });
}
groupChatEmitters.emitStateChange?.(groupChatId, 'idle');
console.log(`[GroupChat:Debug] Emitted state change: idle`);
console.log(`[GroupChat:Debug] =============================================`);
// Don't send to regular exit handler
return;
}
// Handle group chat participant exit - route buffered output and update participant state
// Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
const participantExitInfo = parseParticipantSessionId(sessionId);
if (participantExitInfo) {
const { groupChatId, participantName } = participantExitInfo;
console.log(`[GroupChat:Debug] ========== PARTICIPANT PROCESS EXIT ==========`);
console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`);
console.log(`[GroupChat:Debug] Participant: ${participantName}`);
console.log(`[GroupChat:Debug] Session ID: ${sessionId}`);
console.log(`[GroupChat:Debug] Exit code: ${code}`);
logger.debug(`[GroupChat] Participant exit: ${participantName} (groupChatId=${groupChatId})`, 'ProcessListener', { sessionId });
// Emit participant state change to show this participant is done working
groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle');
console.log(`[GroupChat:Debug] Emitted participant state: idle`);
// Route the buffered output now that process is complete
// IMPORTANT: We must wait for the response to be logged before triggering synthesis
// to avoid a race condition where synthesis reads the log before the response is written
const bufferedOutput = groupChatOutputBuffers.get(sessionId);
console.log(`[GroupChat:Debug] Buffered output length: ${bufferedOutput?.length ?? 0}`);
// Helper function to mark participant and potentially trigger synthesis
const markAndMaybeSynthesize = () => {
const isLastParticipant = markParticipantResponded(groupChatId, participantName);
console.log(`[GroupChat:Debug] Is last participant to respond: ${isLastParticipant}`);
if (isLastParticipant && processManager && agentDetector) {
// All participants have responded - spawn moderator synthesis round
console.log(`[GroupChat:Debug] All participants responded - spawning synthesis round...`);
logger.info('[GroupChat] All participants responded, spawning moderator synthesis', 'ProcessListener', { groupChatId });
spawnModeratorSynthesis(groupChatId, processManager, agentDetector).catch(err => {
console.error(`[GroupChat:Debug] ERROR spawning synthesis:`, err);
logger.error('[GroupChat] Failed to spawn moderator synthesis', 'ProcessListener', { error: String(err), groupChatId });
});
} else if (!isLastParticipant) {
// More participants pending
console.log(`[GroupChat:Debug] Waiting for more participants to respond...`);
}
};
if (bufferedOutput) {
console.log(`[GroupChat:Debug] Raw buffered output preview: "${bufferedOutput.substring(0, 300)}${bufferedOutput.length > 300 ? '...' : ''}"`);
void (async () => {
try {
const chat = await loadGroupChat(groupChatId);
console.log(`[GroupChat:Debug] Chat loaded for participant parsing: ${chat?.name || 'null'}`);
const agentType = chat?.participants.find(p => p.name === participantName)?.agentId;
console.log(`[GroupChat:Debug] Agent type for parsing: ${agentType}`);
const parsedText = extractTextFromStreamJson(bufferedOutput, agentType);
console.log(`[GroupChat:Debug] Parsed text length: ${parsedText.length}`);
console.log(`[GroupChat:Debug] Parsed text preview: "${parsedText.substring(0, 200)}${parsedText.length > 200 ? '...' : ''}"`);
if (parsedText.trim()) {
console.log(`[GroupChat:Debug] Routing agent response from ${participantName}...`);
// Await the response logging before marking participant as responded
await routeAgentResponse(groupChatId, participantName, parsedText, processManager ?? undefined);
console.log(`[GroupChat:Debug] Successfully routed agent response from ${participantName}`);
} else {
console.log(`[GroupChat:Debug] WARNING: Parsed text is empty for ${participantName}!`);
}
} catch (err) {
console.error(`[GroupChat:Debug] ERROR loading chat for participant:`, err);
logger.error('[GroupChat] Failed to load chat for participant output parsing', 'ProcessListener', { error: String(err), participant: participantName });
try {
const parsedText = extractTextFromStreamJson(bufferedOutput);
if (parsedText.trim()) {
await routeAgentResponse(groupChatId, participantName, parsedText, processManager ?? undefined);
}
} catch (routeErr) {
console.error(`[GroupChat:Debug] ERROR routing agent response (fallback):`, routeErr);
logger.error('[GroupChat] Failed to route agent response', 'ProcessListener', { error: String(routeErr), participant: participantName });
}
}
})().finally(() => {
groupChatOutputBuffers.delete(sessionId);
console.log(`[GroupChat:Debug] Cleared output buffer for participant session`);
// Mark participant and trigger synthesis AFTER logging is complete
markAndMaybeSynthesize();
});
} else {
console.log(`[GroupChat:Debug] WARNING: No buffered output for participant ${participantName}!`);
// No output to log, so mark participant as responded immediately
markAndMaybeSynthesize();
}
console.log(`[GroupChat:Debug] ===============================================`);
// Don't send to regular exit handler
return;
}
mainWindow?.webContents.send('process:exit', sessionId, code);
// Broadcast exit to web clients
if (webServer) {
// Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp}
const baseSessionId = sessionId.replace(/-ai-[^-]+$|-terminal$|-batch-\d+$|-synopsis-\d+$/, '');
webServer.broadcastToSessionClients(baseSessionId, {
type: 'session_exit',
sessionId: baseSessionId,
exitCode: code,
timestamp: Date.now(),
});
}
});
processManager.on('session-id', (sessionId: string, agentSessionId: string) => {
// Handle group chat participant session ID - store the agent's session ID
// Session ID format: group-chat-{groupChatId}-participant-{name}-{uuid|timestamp}
const participantSessionInfo = parseParticipantSessionId(sessionId);
if (participantSessionInfo) {
const { groupChatId, participantName } = participantSessionInfo;
// Update the participant with the agent's session ID
updateParticipant(groupChatId, participantName, { agentSessionId }).then(async () => {
// Emit participants changed so UI updates with the new session ID
const chat = await loadGroupChat(groupChatId);
if (chat) {
groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants);
}
}).catch(err => {
logger.error('[GroupChat] Failed to update participant agentSessionId', 'ProcessListener', { error: String(err), participant: participantName });
});
// Don't return - still send to renderer for logging purposes
}
// Handle group chat moderator session ID - store the real agent session ID
// Session ID format: group-chat-{groupChatId}-moderator-{timestamp}
const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-\d+$/);
if (moderatorMatch) {
const groupChatId = moderatorMatch[1];
// Update the group chat with the moderator's real agent session ID
// Store in moderatorAgentSessionId (not moderatorSessionId which is the routing prefix)
updateGroupChat(groupChatId, { moderatorAgentSessionId: agentSessionId }).then(() => {
// Emit session ID change event so UI updates with the new session ID
groupChatEmitters.emitModeratorSessionIdChanged?.(groupChatId, agentSessionId);
}).catch((err: unknown) => {
logger.error('[GroupChat] Failed to update moderator agent session ID', 'ProcessListener', { error: String(err), groupChatId });
});
// Don't return - still send to renderer for logging purposes
}
mainWindow?.webContents.send('process:session-id', sessionId, agentSessionId);
});
// Handle slash commands from Claude Code init message
processManager.on('slash-commands', (sessionId: string, slashCommands: string[]) => {
mainWindow?.webContents.send('process:slash-commands', sessionId, slashCommands);
});
// Handle thinking/streaming content chunks from AI agents
// Emitted when agents produce partial text events (isPartial: true)
// Renderer decides whether to display based on tab's showThinking setting
processManager.on('thinking-chunk', (sessionId: string, content: string) => {
mainWindow?.webContents.send('process:thinking-chunk', sessionId, content);
});
// Handle stderr separately from runCommand (for clean command execution)
processManager.on('stderr', (sessionId: string, data: string) => {
mainWindow?.webContents.send('process:stderr', sessionId, data);
});
// Handle command exit (from runCommand - separate from PTY exit)
processManager.on('command-exit', (sessionId: string, code: number) => {
mainWindow?.webContents.send('process:command-exit', sessionId, code);
});
// Handle usage statistics from AI responses
processManager.on('usage', (sessionId: string, usageStats: {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalCostUsd: number;
contextWindow: number;
reasoningTokens?: number; // Separate reasoning tokens (Codex o3/o4-mini)
}) => {
// Handle group chat participant usage - update participant stats
const participantUsageInfo = parseParticipantSessionId(sessionId);
if (participantUsageInfo) {
const { groupChatId, participantName } = participantUsageInfo;
// Calculate context usage percentage
const totalTokens = usageStats.inputTokens + usageStats.outputTokens;
const contextUsage = usageStats.contextWindow > 0
? Math.round((totalTokens / usageStats.contextWindow) * 100)
: 0;
// Update participant with usage stats
updateParticipant(groupChatId, participantName, {
contextUsage,
tokenCount: totalTokens,
totalCost: usageStats.totalCostUsd,
}).then(async () => {
// Emit participants changed so UI updates
const chat = await loadGroupChat(groupChatId);
if (chat) {
groupChatEmitters.emitParticipantsChanged?.(groupChatId, chat.participants);
}
}).catch(err => {
logger.error('[GroupChat] Failed to update participant usage', 'ProcessListener', {
error: String(err),
participant: participantName,
});
});
// Still send to renderer for consistency
}
// Handle group chat moderator usage - emit for UI
const moderatorMatch = sessionId.match(/^group-chat-(.+)-moderator-/);
if (moderatorMatch) {
const groupChatId = moderatorMatch[1];
// Calculate context usage percentage for moderator display
const totalTokens = usageStats.inputTokens + usageStats.outputTokens;
const contextUsage = usageStats.contextWindow > 0
? Math.round((totalTokens / usageStats.contextWindow) * 100)
: 0;
// Emit moderator usage for the moderator card
groupChatEmitters.emitModeratorUsage?.(groupChatId, {
contextUsage,
totalCost: usageStats.totalCostUsd,
tokenCount: totalTokens,
});
}
mainWindow?.webContents.send('process:usage', sessionId, usageStats);
});
// Handle agent errors (auth expired, token exhaustion, rate limits, etc.)
processManager.on('agent-error', (sessionId: string, agentError: {
type: string;
message: string;
recoverable: boolean;
agentId: string;
sessionId?: string;
timestamp: number;
raw?: {
exitCode?: number;
stderr?: string;
stdout?: string;
errorLine?: string;
};
}) => {
logger.info(`Agent error detected: ${agentError.type}`, 'AgentError', {
sessionId,
agentId: agentError.agentId,
errorType: agentError.type,
message: agentError.message,
recoverable: agentError.recoverable,
});
mainWindow?.webContents.send('agent:error', sessionId, agentError);
});
}
}