mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
- Split monolithic CLAUDE.md into focused, indexed sub-docs for faster onboarding 📚 - Added deep Agent support guide: capabilities, flags, parsers, storage, and adding agents 🤖 - Documented full `window.maestro` IPC surface with clearer namespaces and History API 📡 - Captured core implementation patterns: processes, security, settings, modals, SSH, Auto Run 🧭 - Published performance playbook for React, IPC batching, caching, and debouncing 🚀 - Formalized Session interface docs, including multi-tab, queues, stats, and error fields 🧩 - Session-level custom agent path now overrides detected binary during spawn 🛠️ - New rendering settings: disable GPU acceleration (early startup) and disable confetti 🖥️ - Confetti effects now respect user preference across celebration overlays and wizard completion 🎊 - File explorer now distinguishes “loading” vs “no files found” empty states 🗂️
3269 lines
124 KiB
TypeScript
3269 lines
124 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 crypto from 'crypto';
|
|
import * as Sentry from '@sentry/electron/main';
|
|
import { IPCMode } 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 { powerManager } from './power-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, registerSpeckitHandlers, registerOpenSpecHandlers, registerContextHandlers, registerMarketplaceHandlers, registerStatsHandlers, registerDocumentGraphHandlers, registerSshRemoteHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount } from './ipc/handlers';
|
|
import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats-db';
|
|
import { groupChatEmitters } from './ipc/handlers/groupChat';
|
|
import { routeModeratorResponse, routeAgentResponse, setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery } from './group-chat/group-chat-router';
|
|
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
|
|
import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery';
|
|
import { initializeSessionStorages } from './storage';
|
|
import { initializeOutputParsers, getOutputParser } from './parsers';
|
|
import { calculateContextTokens } from './parsers/usage-aggregator';
|
|
import { DEMO_MODE, DEMO_DATA_PATH } from './constants';
|
|
import type { SshRemoteConfig } from '../shared/types';
|
|
import { initAutoUpdater } from './auto-updater';
|
|
import { readDirRemote, readFileRemote, statRemote, directorySizeRemote, renameRemote, deleteRemote, countItemsRemote } from './utils/remote-fs';
|
|
import { checkWslEnvironment } from './utils/wslDetector';
|
|
|
|
// ============================================================================
|
|
// 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
|
|
}
|
|
|
|
// ============================================================================
|
|
// Data Directory Configuration (MUST happen before any Store initialization)
|
|
// ============================================================================
|
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
|
|
// Capture the production data path before any modification
|
|
// Used for stores that should be shared between dev and prod (e.g., agent configs)
|
|
const productionDataPath = app.getPath('userData');
|
|
|
|
// 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}`);
|
|
}
|
|
|
|
// Development mode: use a separate data directory to allow running alongside production
|
|
// This prevents database lock conflicts (e.g., Service Worker storage)
|
|
// Set USE_PROD_DATA=1 to use the production data directory instead (requires closing production app)
|
|
if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) {
|
|
const devDataPath = path.join(app.getPath('userData'), '..', 'maestro-dev');
|
|
app.setPath('userData', devDataPath);
|
|
console.log(`[DEV MODE] Using data directory: ${devDataPath}`);
|
|
} else if (isDevelopment && process.env.USE_PROD_DATA) {
|
|
console.log(`[DEV MODE] Using production data directory: ${app.getPath('userData')}`);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Store Initialization (after userData path is configured)
|
|
// ============================================================================
|
|
|
|
const bootstrapStore = new Store<BootstrapSettings>({
|
|
name: 'maestro-bootstrap',
|
|
cwd: app.getPath('userData'),
|
|
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
|
|
// If no custom sync path, use the current userData path (dev or prod depending on mode)
|
|
const syncPath = getSyncPath() || app.getPath('userData');
|
|
|
|
// Log the paths being used for debugging session persistence issues
|
|
console.log(`[STARTUP] userData path: ${app.getPath('userData')}`);
|
|
console.log(`[STARTUP] syncPath (sessions/settings): ${syncPath}`);
|
|
console.log(`[STARTUP] productionDataPath (agent configs): ${productionDataPath}`);
|
|
|
|
// Initialize Sentry for crash reporting
|
|
// Only enable in production - skip during development to avoid noise from hot-reload artifacts
|
|
// Check if crash reporting is enabled (default: true for opt-out behavior)
|
|
const earlySettingsStore = new Store<{ crashReportingEnabled: boolean; disableGpuAcceleration: boolean }>({
|
|
name: 'maestro-settings',
|
|
cwd: syncPath, // Use same path as main settings store
|
|
});
|
|
const crashReportingEnabled = earlySettingsStore.get('crashReportingEnabled', true);
|
|
|
|
// Disable GPU hardware acceleration if user has opted out
|
|
// Must be called before app.ready event
|
|
const disableGpuAcceleration = earlySettingsStore.get('disableGpuAcceleration', false);
|
|
if (disableGpuAcceleration) {
|
|
app.disableHardwareAcceleration();
|
|
console.log('[STARTUP] GPU hardware acceleration disabled by user preference');
|
|
}
|
|
|
|
if (crashReportingEnabled && !isDevelopment) {
|
|
Sentry.init({
|
|
dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568',
|
|
// Set release version for better debugging
|
|
release: app.getVersion(),
|
|
// Use Classic IPC mode to avoid "sentry-ipc:// URL scheme not supported" errors
|
|
// See: https://github.com/getsentry/sentry-electron/issues/661
|
|
ipcMode: IPCMode.Classic,
|
|
// 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;
|
|
},
|
|
});
|
|
}
|
|
|
|
// 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;
|
|
// SSH remote execution
|
|
sshRemotes: SshRemoteConfig[];
|
|
defaultSshRemoteId: string | null;
|
|
// Unique installation identifier (generated once on first run)
|
|
installationId: string | null;
|
|
}
|
|
|
|
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: (() => {
|
|
// Windows: $SHELL doesn't exist; default to PowerShell
|
|
if (process.platform === 'win32') {
|
|
return 'powershell';
|
|
}
|
|
// Unix: Respect user's configured login shell from $SHELL
|
|
const shellPath = process.env.SHELL;
|
|
if (shellPath) {
|
|
const shellName = path.basename(shellPath);
|
|
// Valid Unix shell IDs from shellDetector.ts (lines 27-34)
|
|
if (['bash', 'zsh', 'fish', 'sh', 'tcsh'].includes(shellName)) {
|
|
return shellName;
|
|
}
|
|
}
|
|
// Fallback to bash (more portable than zsh on older Unix systems)
|
|
return 'bash';
|
|
})(),
|
|
webAuthEnabled: false,
|
|
webAuthToken: null,
|
|
webInterfaceUseCustomPort: false,
|
|
webInterfaceCustomPort: 8080,
|
|
sshRemotes: [],
|
|
defaultSshRemoteId: null,
|
|
installationId: null,
|
|
},
|
|
});
|
|
|
|
// Generate installation ID on first run (one-time generation)
|
|
// This creates a unique identifier per Maestro installation for telemetry differentiation
|
|
let installationId = store.get('installationId');
|
|
if (!installationId) {
|
|
installationId = crypto.randomUUID();
|
|
store.set('installationId', installationId);
|
|
logger.info('Generated new installation ID', 'Startup', { installationId });
|
|
}
|
|
|
|
// Add installation ID to Sentry for error correlation across installations
|
|
if (crashReportingEnabled && !isDevelopment) {
|
|
Sentry.setTag('installationId', installationId);
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Agent configs are ALWAYS stored in the production path, even in dev mode
|
|
// This ensures agent paths, custom args, and env vars are shared between dev and prod
|
|
// (They represent machine-level configuration, not session/project data)
|
|
const agentConfigsStore = new Store<AgentConfigsData>({
|
|
name: 'maestro-agent-configs',
|
|
cwd: productionDataPath,
|
|
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: {},
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Get SSH remote configuration by ID.
|
|
* Returns undefined if not found.
|
|
*/
|
|
function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined {
|
|
const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[];
|
|
return sshRemotes.find((r) => r.id === sshRemoteId);
|
|
}
|
|
|
|
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;
|
|
|
|
/**
|
|
* Safely send IPC message to renderer.
|
|
* Handles cases where the renderer has been disposed (e.g., GPU crash, window closing).
|
|
* This prevents "Render frame was disposed before WebFrameMain could be accessed" errors.
|
|
*/
|
|
function safeSend(channel: string, ...args: unknown[]): void {
|
|
try {
|
|
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
|
|
mainWindow.webContents.send(channel, ...args);
|
|
}
|
|
} catch (error) {
|
|
// Silently ignore - renderer is not available
|
|
// This can happen during GPU crashes, window closing, or app shutdown
|
|
logger.debug(`Failed to send IPC message to renderer: ${channel}`, 'IPC', { error: String(error) });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
// Note: 'thinking' logs are already excluded since they have a distinct source type
|
|
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,
|
|
// Worktree subagent support
|
|
parentSessionId: s.parentSessionId || null,
|
|
worktreeBranch: s.worktreeBranch || null,
|
|
};
|
|
});
|
|
});
|
|
|
|
// 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
|
|
// Filter out thinking and tool logs - these should never be shown on the web interface
|
|
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];
|
|
const rawLogs = targetTab?.logs || [];
|
|
// Web interface should never show thinking/tool logs regardless of desktop settings
|
|
aiLogs = rawLogs.filter((log: any) => log.source !== 'thinking' && log.source !== 'tool');
|
|
}
|
|
|
|
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
|
|
});
|
|
|
|
// Check for WSL + Windows mount issues early
|
|
checkWslEnvironment(process.cwd());
|
|
|
|
// 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');
|
|
}
|
|
|
|
// Initialize stats database for usage tracking
|
|
logger.info('Initializing stats database', 'Startup');
|
|
try {
|
|
initializeStatsDB();
|
|
logger.info('Stats database initialized', 'Startup');
|
|
} catch (error) {
|
|
// Stats initialization failed - log error but continue with app startup
|
|
// Stats will be unavailable but the app will still function
|
|
logger.error(`Failed to initialize stats database: ${error}`, 'Startup');
|
|
logger.warn('Continuing without stats - usage tracking 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();
|
|
}
|
|
});
|
|
|
|
// Track if quit has been confirmed by user (or no busy agents)
|
|
let quitConfirmed = false;
|
|
|
|
// Handle quit confirmation from renderer
|
|
ipcMain.on('app:quitConfirmed', () => {
|
|
logger.info('Quit confirmed by renderer', 'Window');
|
|
quitConfirmed = true;
|
|
app.quit();
|
|
});
|
|
|
|
// Handle quit cancellation (user declined)
|
|
ipcMain.on('app:quitCancelled', () => {
|
|
logger.info('Quit cancelled by renderer', 'Window');
|
|
// Nothing to do - app stays running
|
|
});
|
|
|
|
// IMPORTANT: This handler must be synchronous for event.preventDefault() to work!
|
|
// Async handlers return a Promise immediately, which breaks preventDefault in Electron.
|
|
app.on('before-quit', (event) => {
|
|
// If quit not yet confirmed, intercept and ask renderer
|
|
if (!quitConfirmed) {
|
|
event.preventDefault();
|
|
|
|
// Ask renderer to check for busy agents
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
logger.info('Requesting quit confirmation from renderer', 'Window');
|
|
mainWindow.webContents.send('app:requestQuitConfirmation');
|
|
} else {
|
|
// No window, just quit
|
|
quitConfirmed = true;
|
|
app.quit();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Quit confirmed - proceed with cleanup (async operations are fire-and-forget)
|
|
logger.info('Application shutting down', 'Shutdown');
|
|
|
|
// Stop history manager watcher
|
|
getHistoryManager().stopWatching();
|
|
|
|
// Stop CLI activity watcher
|
|
if (cliActivityWatcher) {
|
|
cliActivityWatcher.close();
|
|
cliActivityWatcher = null;
|
|
}
|
|
|
|
// Clean up active grooming sessions (context merge/transfer operations)
|
|
const groomingSessionCount = getActiveGroomingSessionCount();
|
|
if (groomingSessionCount > 0 && processManager) {
|
|
logger.info(`Cleaning up ${groomingSessionCount} active grooming session(s)`, 'Shutdown');
|
|
// Fire and forget - don't await
|
|
cleanupAllGroomingSessions(processManager).catch(err => {
|
|
logger.error(`Error cleaning up grooming sessions: ${err}`, 'Shutdown');
|
|
});
|
|
}
|
|
|
|
// Clean up all running processes
|
|
logger.info('Killing all running processes', 'Shutdown');
|
|
processManager?.killAll();
|
|
|
|
// Stop tunnel and web server (fire and forget)
|
|
logger.info('Stopping tunnel', 'Shutdown');
|
|
tunnelManager.stop().catch(err => {
|
|
logger.error(`Error stopping tunnel: ${err}`, 'Shutdown');
|
|
});
|
|
|
|
logger.info('Stopping web server', 'Shutdown');
|
|
webServer?.stop().catch(err => {
|
|
logger.error(`Error stopping web server: ${err}`, 'Shutdown');
|
|
});
|
|
|
|
// Close stats database
|
|
logger.info('Closing stats database', 'Shutdown');
|
|
closeStatsDB();
|
|
|
|
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;
|
|
// Multi-document progress fields
|
|
totalDocuments?: number;
|
|
currentDocumentIndex?: number;
|
|
totalTasksAcrossAllDocs?: number;
|
|
completedTasksAcrossAllDocs?: number;
|
|
} | 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;
|
|
});
|
|
|
|
// Broadcast session state change to web clients (for real-time busy/idle updates)
|
|
// This is called directly from the renderer to bypass debounced persistence
|
|
// which resets state to 'idle' before saving
|
|
ipcMain.handle('web:broadcastSessionState', async (_, sessionId: string, state: string, additionalData?: {
|
|
name?: string;
|
|
toolType?: string;
|
|
inputMode?: string;
|
|
cwd?: string;
|
|
}) => {
|
|
if (webServer && webServer.getWebClientCount() > 0) {
|
|
webServer.broadcastSessionStateChange(sessionId, state, additionalData);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
// Git operations - extracted to src/main/ipc/handlers/git.ts
|
|
registerGitHandlers({
|
|
settingsStore: store,
|
|
});
|
|
|
|
// Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts
|
|
registerAutorunHandlers({
|
|
mainWindow,
|
|
getMainWindow: () => mainWindow,
|
|
app,
|
|
settingsStore: store,
|
|
});
|
|
|
|
// 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,
|
|
settingsStore: store,
|
|
});
|
|
|
|
// Process management operations - extracted to src/main/ipc/handlers/process.ts
|
|
registerProcessHandlers({
|
|
getProcessManager: () => processManager,
|
|
getAgentDetector: () => agentDetector,
|
|
agentConfigsStore,
|
|
settingsStore: store,
|
|
getMainWindow: () => mainWindow,
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
|
|
// Register Spec Kit handlers (no dependencies needed)
|
|
registerSpeckitHandlers();
|
|
|
|
// Register OpenSpec handlers (no dependencies needed)
|
|
registerOpenSpecHandlers();
|
|
|
|
// Register Context Merge handlers for session context transfer and grooming
|
|
registerContextHandlers({
|
|
getMainWindow: () => mainWindow,
|
|
getProcessManager: () => processManager,
|
|
getAgentDetector: () => agentDetector,
|
|
});
|
|
|
|
// Register Marketplace handlers for fetching and importing playbooks
|
|
registerMarketplaceHandlers({
|
|
app,
|
|
settingsStore: store,
|
|
});
|
|
|
|
// Register Stats handlers for usage tracking
|
|
registerStatsHandlers({
|
|
getMainWindow: () => mainWindow,
|
|
settingsStore: store,
|
|
});
|
|
|
|
// Register Document Graph handlers for file watching
|
|
registerDocumentGraphHandlers({
|
|
getMainWindow: () => mainWindow,
|
|
app,
|
|
});
|
|
|
|
// Register SSH Remote handlers for managing SSH configurations
|
|
registerSshRemoteHandlers({
|
|
settingsStore: store,
|
|
});
|
|
|
|
// 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) => {
|
|
// Resolve SSH remote name if session has SSH config
|
|
let sshRemoteName: string | undefined;
|
|
if (s.sessionSshRemoteConfig?.enabled && s.sessionSshRemoteConfig.remoteId) {
|
|
const sshConfig = getSshRemoteById(s.sessionSshRemoteConfig.remoteId);
|
|
sshRemoteName = sshConfig?.name;
|
|
}
|
|
return {
|
|
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,
|
|
sshRemoteName,
|
|
};
|
|
});
|
|
});
|
|
|
|
// 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, sshRemoteId?: string) => {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await readDirRemote(dirPath, sshConfig);
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to read remote directory');
|
|
}
|
|
// Map remote entries to match local format (isFile derived from !isDirectory && !isSymlink)
|
|
// Include full path for recursive directory scanning (e.g., document graph)
|
|
// Use POSIX path joining for remote paths (always forward slashes)
|
|
return result.data!.map((entry) => ({
|
|
name: entry.name,
|
|
isDirectory: entry.isDirectory,
|
|
isFile: !entry.isDirectory && !entry.isSymlink,
|
|
path: dirPath.endsWith('/') ? `${dirPath}${entry.name}` : `${dirPath}/${entry.name}`,
|
|
}));
|
|
}
|
|
|
|
// Local: use standard fs operations
|
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
// Convert Dirent objects to plain objects for IPC serialization
|
|
// Include full path for recursive directory scanning (e.g., document graph)
|
|
return entries.map((entry: any) => ({
|
|
name: entry.name,
|
|
isDirectory: entry.isDirectory(),
|
|
isFile: entry.isFile(),
|
|
path: path.join(dirPath, entry.name),
|
|
}));
|
|
});
|
|
|
|
ipcMain.handle('fs:readFile', async (_, filePath: string, sshRemoteId?: string) => {
|
|
try {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await readFileRemote(filePath, sshConfig);
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to read remote file');
|
|
}
|
|
// For images over SSH, we'd need to base64 encode on remote and decode here
|
|
// For now, return raw content (text files work, binary images may have issues)
|
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico'];
|
|
const isImage = imageExtensions.includes(ext || '');
|
|
if (isImage) {
|
|
// The remote readFile returns raw bytes as string - convert to base64 data URL
|
|
const mimeType = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
|
|
const base64 = Buffer.from(result.data!, 'binary').toString('base64');
|
|
return `data:${mimeType};base64,${base64}`;
|
|
}
|
|
return result.data!;
|
|
}
|
|
|
|
// Local: use standard fs operations
|
|
// 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, sshRemoteId?: string) => {
|
|
try {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await statRemote(filePath, sshConfig);
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to get remote file stats');
|
|
}
|
|
// Map remote stat result to match local format
|
|
// Note: remote stat doesn't provide createdAt (birthtime), use mtime as fallback
|
|
const mtimeDate = new Date(result.data!.mtime);
|
|
return {
|
|
size: result.data!.size,
|
|
createdAt: mtimeDate.toISOString(), // Fallback: use mtime for createdAt
|
|
modifiedAt: mtimeDate.toISOString(),
|
|
isDirectory: result.data!.isDirectory,
|
|
isFile: !result.data!.isDirectory,
|
|
};
|
|
}
|
|
|
|
// Local: use standard fs operations
|
|
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}`);
|
|
}
|
|
});
|
|
|
|
// Calculate total size of a directory recursively
|
|
// Respects the same ignore patterns as loadFileTree (node_modules, __pycache__)
|
|
ipcMain.handle('fs:directorySize', async (_, dirPath: string, sshRemoteId?: string) => {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
// Fetch size and counts in parallel for SSH remotes
|
|
const [sizeResult, countResult] = await Promise.all([
|
|
directorySizeRemote(dirPath, sshConfig),
|
|
countItemsRemote(dirPath, sshConfig),
|
|
]);
|
|
if (!sizeResult.success) {
|
|
throw new Error(sizeResult.error || 'Failed to get remote directory size');
|
|
}
|
|
return {
|
|
totalSize: sizeResult.data!,
|
|
fileCount: countResult.success ? countResult.data!.fileCount : 0,
|
|
folderCount: countResult.success ? countResult.data!.folderCount : 0,
|
|
};
|
|
}
|
|
|
|
// Local: use standard fs operations
|
|
let totalSize = 0;
|
|
let fileCount = 0;
|
|
let folderCount = 0;
|
|
|
|
const calculateSize = async (currentPath: string, depth: number = 0): Promise<void> => {
|
|
// Limit recursion depth to match file tree loading
|
|
if (depth >= 10) return;
|
|
|
|
try {
|
|
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
// Skip common ignore patterns (same as loadFileTree)
|
|
if (entry.name === 'node_modules' || entry.name === '__pycache__') {
|
|
continue;
|
|
}
|
|
|
|
const fullPath = path.join(currentPath, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
folderCount++;
|
|
await calculateSize(fullPath, depth + 1);
|
|
} else if (entry.isFile()) {
|
|
fileCount++;
|
|
try {
|
|
const stats = await fs.stat(fullPath);
|
|
totalSize += stats.size;
|
|
} catch {
|
|
// Skip files we can't stat (permissions, etc.)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip directories we can't read
|
|
}
|
|
};
|
|
|
|
await calculateSize(dirPath);
|
|
|
|
return {
|
|
totalSize,
|
|
fileCount,
|
|
folderCount
|
|
};
|
|
});
|
|
|
|
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}`);
|
|
}
|
|
});
|
|
|
|
// Rename a file or folder (supports SSH remote)
|
|
ipcMain.handle('fs:rename', async (_, oldPath: string, newPath: string, sshRemoteId?: string) => {
|
|
try {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await renameRemote(oldPath, newPath, sshConfig);
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to rename remote file');
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
// Local: standard fs rename
|
|
await fs.rename(oldPath, newPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(`Failed to rename: ${error}`);
|
|
}
|
|
});
|
|
|
|
// Delete a file or folder (with recursive option for folders, supports SSH remote)
|
|
ipcMain.handle('fs:delete', async (_, targetPath: string, options?: { recursive?: boolean; sshRemoteId?: string }) => {
|
|
try {
|
|
const sshRemoteId = options?.sshRemoteId;
|
|
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await deleteRemote(targetPath, sshConfig, options?.recursive ?? true);
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Failed to delete remote file');
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
// Local: standard fs delete
|
|
const stat = await fs.stat(targetPath);
|
|
if (stat.isDirectory()) {
|
|
await fs.rm(targetPath, { recursive: options?.recursive ?? true, force: true });
|
|
} else {
|
|
await fs.unlink(targetPath);
|
|
}
|
|
return { success: true };
|
|
} catch (error) {
|
|
throw new Error(`Failed to delete: ${error}`);
|
|
}
|
|
});
|
|
|
|
// Count items in a directory (for delete confirmation, supports SSH remote)
|
|
ipcMain.handle('fs:countItems', async (_, dirPath: string, sshRemoteId?: string) => {
|
|
try {
|
|
// SSH remote: dispatch to remote fs operations
|
|
if (sshRemoteId) {
|
|
const sshConfig = getSshRemoteById(sshRemoteId);
|
|
if (!sshConfig) {
|
|
throw new Error(`SSH remote not found: ${sshRemoteId}`);
|
|
}
|
|
const result = await countItemsRemote(dirPath, sshConfig);
|
|
if (!result.success || !result.data) {
|
|
throw new Error(result.error || 'Failed to count remote items');
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
// Local: standard fs count
|
|
let fileCount = 0;
|
|
let folderCount = 0;
|
|
|
|
const countRecursive = async (dir: string) => {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory()) {
|
|
folderCount++;
|
|
await countRecursive(path.join(dir, entry.name));
|
|
} else {
|
|
fileCount++;
|
|
}
|
|
}
|
|
};
|
|
|
|
await countRecursive(dirPath);
|
|
return { fileCount, folderCount };
|
|
} catch (error) {
|
|
throw new Error(`Failed to count items: ${error}`);
|
|
}
|
|
});
|
|
|
|
// Fetch image from URL and return as base64 data URL (avoids CORS issues)
|
|
ipcMain.handle('fs:fetchImageAsBase64', async (_, url: string) => {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
const base64 = buffer.toString('base64');
|
|
// Determine mime type from content-type header or URL
|
|
const contentType = response.headers.get('content-type') || 'image/png';
|
|
return `data:${contentType};base64,${base64}`;
|
|
} catch (error) {
|
|
// Return null on failure - let caller handle gracefully
|
|
logger.warn(`Failed to fetch image from ${url}: ${error}`, 'fs:fetchImageAsBase64');
|
|
return null;
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
|
|
// TTS queue to prevent audio overlap - enforces minimum delay between TTS calls
|
|
const TTS_MIN_DELAY_MS = 15000; // 15 seconds between TTS calls
|
|
let lastTtsEndTime = 0;
|
|
const ttsQueue: Array<{
|
|
text: string;
|
|
command?: string;
|
|
resolve: (result: { success: boolean; ttsId?: number; error?: string }) => void;
|
|
}> = [];
|
|
let isTtsProcessing = false;
|
|
|
|
// Process the next item in the TTS queue
|
|
const processNextTts = async () => {
|
|
if (isTtsProcessing || ttsQueue.length === 0) return;
|
|
|
|
isTtsProcessing = true;
|
|
const item = ttsQueue.shift()!;
|
|
|
|
// Calculate delay needed to maintain minimum gap
|
|
const now = Date.now();
|
|
const timeSinceLastTts = now - lastTtsEndTime;
|
|
const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts);
|
|
|
|
if (delayNeeded > 0) {
|
|
logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS');
|
|
await new Promise(resolve => setTimeout(resolve, delayNeeded));
|
|
}
|
|
|
|
// Execute the TTS
|
|
const result = await executeTts(item.text, item.command);
|
|
item.resolve(result);
|
|
|
|
// Record when this TTS ended
|
|
lastTtsEndTime = Date.now();
|
|
isTtsProcessing = false;
|
|
|
|
// Process next item in queue
|
|
processNextTts();
|
|
};
|
|
|
|
// Execute TTS - the actual implementation
|
|
// Returns a Promise that resolves when the TTS process completes (not just when it starts)
|
|
const executeTts = async (text: string, command?: string): Promise<{ success: boolean; ttsId?: number; error?: string }> => {
|
|
console.log('[TTS Main] executeTts 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);
|
|
|
|
// 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 });
|
|
|
|
// Return a Promise that resolves when the TTS process completes
|
|
return new Promise((resolve) => {
|
|
let resolved = false;
|
|
let stderrOutput = '';
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
console.log('[TTS Main] Writing to stdin:', text);
|
|
child.stdin.write(text, 'utf8', (err) => {
|
|
if (err) {
|
|
console.error('[TTS Main] stdin write error:', err);
|
|
} else {
|
|
console.log('[TTS Main] stdin write completed, ending stream');
|
|
}
|
|
child.stdin!.end();
|
|
});
|
|
} else {
|
|
console.error('[TTS Main] No stdin available on child process');
|
|
}
|
|
|
|
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);
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve({ success: false, ttsId, error: String(err) });
|
|
}
|
|
});
|
|
|
|
// Capture stderr for debugging
|
|
if (child.stderr) {
|
|
child.stderr.on('data', (data) => {
|
|
stderrOutput += data.toString();
|
|
});
|
|
}
|
|
|
|
child.on('close', (code, signal) => {
|
|
console.log('[TTS Main] Process exited with code:', code, 'signal:', signal);
|
|
// Always log close event for debugging production issues
|
|
logger.info('TTS process closed', 'TTS', {
|
|
ttsId,
|
|
exitCode: code,
|
|
signal,
|
|
stderr: stderrOutput || '(none)',
|
|
command: fullCommand,
|
|
});
|
|
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);
|
|
});
|
|
|
|
// Resolve the promise now that TTS has completed
|
|
if (!resolved) {
|
|
resolved = true;
|
|
resolve({ success: code === 0, 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,
|
|
});
|
|
});
|
|
} 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) };
|
|
}
|
|
};
|
|
|
|
// Audio feedback using system TTS command - queued to prevent overlap
|
|
ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => {
|
|
// Add to queue and return a promise that resolves when this TTS completes
|
|
return new Promise<{ success: boolean; ttsId?: number; error?: string }>((resolve) => {
|
|
ttsQueue.push({ text, command, resolve });
|
|
logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS');
|
|
processNextTts();
|
|
});
|
|
});
|
|
|
|
// 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
|
|
// ==========================================================================
|
|
|
|
// Get the unique installation ID for this Maestro installation
|
|
ipcMain.handle('leaderboard:getInstallationId', async () => {
|
|
return store.get('installationId') || null;
|
|
});
|
|
|
|
// Submit leaderboard entry to runmaestro.ai
|
|
ipcMain.handle(
|
|
'leaderboard:submit',
|
|
async (
|
|
_event,
|
|
data: {
|
|
email: string;
|
|
displayName: string;
|
|
githubUsername?: string;
|
|
twitterHandle?: string;
|
|
linkedinHandle?: string;
|
|
discordUsername?: string;
|
|
blueskyHandle?: 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
|
|
// Delta mode for multi-device aggregation
|
|
deltaMs?: number; // Time in milliseconds to ADD to server-side cumulative total
|
|
deltaRuns?: number; // Number of runs to ADD to server-side total runs count
|
|
// Installation tracking for multi-device differentiation
|
|
installationId?: string; // Unique GUID per Maestro installation
|
|
clientTotalTimeMs?: number; // Client's self-proclaimed total time (for discrepancy detection)
|
|
}
|
|
): 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;
|
|
};
|
|
// Server-side totals for multi-device sync
|
|
serverTotals?: {
|
|
cumulativeTimeMs: number;
|
|
totalRuns: number;
|
|
};
|
|
}> => {
|
|
try {
|
|
// Auto-inject installation ID if not provided
|
|
const installationId = data.installationId || store.get('installationId') || undefined;
|
|
|
|
logger.info('Submitting leaderboard entry', 'Leaderboard', {
|
|
displayName: data.displayName,
|
|
email: data.email.substring(0, 3) + '***',
|
|
badgeLevel: data.badgeLevel,
|
|
hasClientToken: !!data.clientToken,
|
|
hasAuthToken: !!data.authToken,
|
|
hasInstallationId: !!installationId,
|
|
hasClientTotalTime: !!data.clientTotalTimeMs,
|
|
});
|
|
|
|
// Prepare submission data with server-expected field names
|
|
// Server expects 'installId' not 'installationId'
|
|
const submissionData = {
|
|
...data,
|
|
installId: installationId, // Map to server field name
|
|
};
|
|
|
|
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(submissionData),
|
|
});
|
|
|
|
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;
|
|
};
|
|
// Server-side totals for multi-device sync
|
|
serverTotals?: {
|
|
cumulativeTimeMs: number;
|
|
totalRuns: number;
|
|
};
|
|
};
|
|
|
|
if (response.ok) {
|
|
logger.info('Leaderboard submission successful', 'Leaderboard', {
|
|
pendingEmailConfirmation: result.pendingEmailConfirmation,
|
|
ranking: result.ranking,
|
|
serverTotals: result.serverTotals,
|
|
});
|
|
return {
|
|
success: true,
|
|
message: result.message || 'Submission received',
|
|
pendingEmailConfirmation: result.pendingEmailConfirmation,
|
|
ranking: result.ranking,
|
|
serverTotals: result.serverTotals,
|
|
};
|
|
} 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',
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Resend confirmation email (self-service auth token recovery)
|
|
ipcMain.handle(
|
|
'leaderboard:resendConfirmation',
|
|
async (
|
|
_event,
|
|
data: {
|
|
email: string;
|
|
clientToken: string;
|
|
}
|
|
): Promise<{
|
|
success: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
}> => {
|
|
try {
|
|
logger.info('Requesting leaderboard confirmation resend', 'Leaderboard', {
|
|
email: data.email.substring(0, 3) + '***',
|
|
});
|
|
|
|
const response = await fetch('https://runmaestro.ai/api/m4estr0/resend-confirmation', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': `Maestro/${app.getVersion()}`,
|
|
},
|
|
body: JSON.stringify({
|
|
email: data.email,
|
|
clientToken: data.clientToken,
|
|
}),
|
|
});
|
|
|
|
const result = await response.json() as {
|
|
success?: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
};
|
|
|
|
if (response.ok && result.success) {
|
|
logger.info('Leaderboard confirmation email resent', 'Leaderboard');
|
|
return {
|
|
success: true,
|
|
message: result.message || 'Confirmation email sent. Please check your inbox.',
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
error: result.error || result.message || `Server error: ${response.status}`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error resending leaderboard confirmation', 'Leaderboard', error);
|
|
return {
|
|
success: false,
|
|
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',
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Sync user stats from server (for new device installations)
|
|
ipcMain.handle(
|
|
'leaderboard:sync',
|
|
async (
|
|
_event,
|
|
data: {
|
|
email: string;
|
|
authToken: string;
|
|
}
|
|
): Promise<{
|
|
success: boolean;
|
|
found: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS';
|
|
data?: {
|
|
displayName: string;
|
|
badgeLevel: number;
|
|
badgeName: string;
|
|
cumulativeTimeMs: number;
|
|
totalRuns: number;
|
|
longestRunMs: number | null;
|
|
longestRunDate: string | null;
|
|
keyboardLevel: number | null;
|
|
coveragePercent: number | null;
|
|
ranking: {
|
|
cumulative: { rank: number; total: number };
|
|
longestRun: { rank: number; total: number } | null;
|
|
};
|
|
};
|
|
}> => {
|
|
try {
|
|
logger.info('Syncing leaderboard stats from server', 'Leaderboard', {
|
|
email: data.email.substring(0, 3) + '***',
|
|
});
|
|
|
|
const response = await fetch('https://runmaestro.ai/api/m4estr0/sync', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': `Maestro/${app.getVersion()}`,
|
|
},
|
|
body: JSON.stringify({
|
|
email: data.email,
|
|
authToken: data.authToken,
|
|
}),
|
|
});
|
|
|
|
const result = await response.json() as {
|
|
success: boolean;
|
|
found?: boolean;
|
|
message?: string;
|
|
error?: string;
|
|
errorCode?: string;
|
|
data?: {
|
|
displayName: string;
|
|
badgeLevel: number;
|
|
badgeName: string;
|
|
cumulativeTimeMs: number;
|
|
totalRuns: number;
|
|
longestRunMs: number | null;
|
|
longestRunDate: string | null;
|
|
keyboardLevel: number | null;
|
|
coveragePercent: number | null;
|
|
ranking: {
|
|
cumulative: { rank: number; total: number };
|
|
longestRun: { rank: number; total: number } | null;
|
|
};
|
|
};
|
|
};
|
|
|
|
if (response.ok && result.success) {
|
|
if (result.found && result.data) {
|
|
logger.info('Leaderboard sync successful', 'Leaderboard', {
|
|
badgeLevel: result.data.badgeLevel,
|
|
cumulativeTimeMs: result.data.cumulativeTimeMs,
|
|
});
|
|
return {
|
|
success: true,
|
|
found: true,
|
|
data: result.data,
|
|
};
|
|
} else {
|
|
logger.info('Leaderboard sync: user not found', 'Leaderboard');
|
|
return {
|
|
success: true,
|
|
found: false,
|
|
message: result.message || 'No existing registration found',
|
|
};
|
|
}
|
|
} else if (response.status === 401) {
|
|
logger.warn('Leaderboard sync: invalid token', 'Leaderboard');
|
|
return {
|
|
success: false,
|
|
found: false,
|
|
error: result.error || 'Invalid authentication token',
|
|
errorCode: 'INVALID_TOKEN',
|
|
};
|
|
} else if (response.status === 403) {
|
|
logger.warn('Leaderboard sync: email not confirmed', 'Leaderboard');
|
|
return {
|
|
success: false,
|
|
found: false,
|
|
error: result.error || 'Email not yet confirmed',
|
|
errorCode: 'EMAIL_NOT_CONFIRMED',
|
|
};
|
|
} else if (response.status === 400) {
|
|
return {
|
|
success: false,
|
|
found: false,
|
|
error: result.error || 'Missing required fields',
|
|
errorCode: 'MISSING_FIELDS',
|
|
};
|
|
} else {
|
|
return {
|
|
success: false,
|
|
found: false,
|
|
error: result.error || `Server error: ${response.status}`,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error syncing from leaderboard server', 'Leaderboard', error);
|
|
return {
|
|
success: false,
|
|
found: 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
|
|
}
|
|
|
|
safeSend('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) => {
|
|
// Remove power block reason for this session
|
|
// This allows system sleep when no AI sessions are active
|
|
powerManager.removeBlockReason(`session:${sessionId}`);
|
|
|
|
// 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 ? '...' : ''}"`);
|
|
|
|
// Handle session recovery and normal processing in an async IIFE
|
|
void (async () => {
|
|
// Check if this is a session_not_found error - if so, recover and retry
|
|
const chat = await loadGroupChat(groupChatId);
|
|
const agentType = chat?.participants.find(p => p.name === participantName)?.agentId;
|
|
|
|
if (needsSessionRecovery(bufferedOutput, agentType)) {
|
|
console.log(`[GroupChat:Debug] Session not found error detected for ${participantName} - initiating recovery`);
|
|
logger.info('[GroupChat] Session recovery needed', 'ProcessListener', { groupChatId, participantName });
|
|
|
|
// Clear the buffer first
|
|
groupChatOutputBuffers.delete(sessionId);
|
|
|
|
// Initiate recovery (clears agentSessionId)
|
|
await initiateSessionRecovery(groupChatId, participantName);
|
|
|
|
// Re-spawn the participant with recovery context
|
|
if (processManager && agentDetector) {
|
|
console.log(`[GroupChat:Debug] Re-spawning ${participantName} with recovery context...`);
|
|
try {
|
|
await respawnParticipantWithRecovery(
|
|
groupChatId,
|
|
participantName,
|
|
processManager,
|
|
agentDetector
|
|
);
|
|
console.log(`[GroupChat:Debug] Successfully re-spawned ${participantName} for recovery`);
|
|
// Don't mark as responded yet - the recovery spawn will complete and trigger this
|
|
} catch (respawnErr) {
|
|
console.error(`[GroupChat:Debug] Failed to respawn ${participantName}:`, respawnErr);
|
|
logger.error('[GroupChat] Failed to respawn participant for recovery', 'ProcessListener', {
|
|
error: String(respawnErr),
|
|
participant: participantName,
|
|
});
|
|
// Mark as responded since recovery failed
|
|
markAndMaybeSynthesize();
|
|
}
|
|
} else {
|
|
console.log(`[GroupChat:Debug] Cannot respawn - processManager or agentDetector not available`);
|
|
markAndMaybeSynthesize();
|
|
}
|
|
console.log(`[GroupChat:Debug] ===============================================`);
|
|
return;
|
|
}
|
|
|
|
// Normal processing - parse and route the response
|
|
try {
|
|
console.log(`[GroupChat:Debug] Chat loaded for participant parsing: ${chat?.name || 'null'}`);
|
|
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;
|
|
}
|
|
|
|
safeSend('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
|
|
}
|
|
|
|
safeSend('process:session-id', sessionId, agentSessionId);
|
|
});
|
|
|
|
// Handle slash commands from Claude Code init message
|
|
processManager.on('slash-commands', (sessionId: string, slashCommands: string[]) => {
|
|
safeSend('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) => {
|
|
safeSend('process:thinking-chunk', sessionId, content);
|
|
});
|
|
|
|
// Handle tool execution events (OpenCode, Codex)
|
|
processManager.on('tool-execution', (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => {
|
|
safeSend('process:tool-execution', sessionId, toolEvent);
|
|
});
|
|
|
|
// Handle stderr separately from runCommand (for clean command execution)
|
|
processManager.on('stderr', (sessionId: string, data: string) => {
|
|
safeSend('process:stderr', sessionId, data);
|
|
});
|
|
|
|
// Handle command exit (from runCommand - separate from PTY exit)
|
|
processManager.on('command-exit', (sessionId: string, code: number) => {
|
|
safeSend('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 using agent-specific logic
|
|
// Note: For group chat, we don't have agent type here, defaults to Claude behavior
|
|
const totalContextTokens = calculateContextTokens(usageStats);
|
|
const contextUsage = usageStats.contextWindow > 0
|
|
? Math.round((totalContextTokens / usageStats.contextWindow) * 100)
|
|
: 0;
|
|
|
|
// Update participant with usage stats
|
|
updateParticipant(groupChatId, participantName, {
|
|
contextUsage,
|
|
tokenCount: totalContextTokens,
|
|
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 using agent-specific logic
|
|
// Note: Moderator is typically Claude, defaults to Claude behavior
|
|
const totalContextTokens = calculateContextTokens(usageStats);
|
|
const contextUsage = usageStats.contextWindow > 0
|
|
? Math.round((totalContextTokens / usageStats.contextWindow) * 100)
|
|
: 0;
|
|
|
|
// Emit moderator usage for the moderator card
|
|
groupChatEmitters.emitModeratorUsage?.(groupChatId, {
|
|
contextUsage,
|
|
totalCost: usageStats.totalCostUsd,
|
|
tokenCount: totalContextTokens,
|
|
});
|
|
}
|
|
|
|
safeSend('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,
|
|
});
|
|
safeSend('agent:error', sessionId, agentError);
|
|
});
|
|
|
|
// Handle query-complete events for stats tracking
|
|
// This is emitted when a batch mode AI query completes (user or auto)
|
|
processManager.on('query-complete', (_sessionId: string, queryData: {
|
|
sessionId: string;
|
|
agentType: string;
|
|
source: 'user' | 'auto';
|
|
startTime: number;
|
|
duration: number;
|
|
projectPath?: string;
|
|
tabId?: string;
|
|
}) => {
|
|
try {
|
|
const db = getStatsDB();
|
|
if (db.isReady()) {
|
|
const id = db.insertQueryEvent({
|
|
sessionId: queryData.sessionId,
|
|
agentType: queryData.agentType,
|
|
source: queryData.source,
|
|
startTime: queryData.startTime,
|
|
duration: queryData.duration,
|
|
projectPath: queryData.projectPath,
|
|
tabId: queryData.tabId,
|
|
});
|
|
logger.debug(`Recorded query event: ${id}`, '[Stats]', {
|
|
sessionId: queryData.sessionId,
|
|
agentType: queryData.agentType,
|
|
source: queryData.source,
|
|
duration: queryData.duration,
|
|
});
|
|
// Broadcast stats update to renderer for real-time dashboard refresh
|
|
safeSend('stats:updated');
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to record query event: ${error}`, '[Stats]', {
|
|
sessionId: queryData.sessionId,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|