diff --git a/main/package.json b/main/package.json index 63209ea..5c1e4c5 100644 --- a/main/package.json +++ b/main/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "tsc -w", "build": "rimraf dist && tsc && npm run copy:assets && npm run bundle:mcp", + "headless": "npm run build && electron dist/main/src/daemon/headless.js", "bundle:mcp": "node build-mcp-bridge.js", "copy:assets": "mkdirp dist/main/src/database/migrations && shx cp src/database/*.sql dist/main/src/database/ && shx cp src/database/migrations/*.sql dist/main/src/database/migrations/", "lint": "eslint src --ext .ts", diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index cbf39df..47cf47a 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -17,6 +17,7 @@ describe('daemon/client import boundary', () => { it('keeps targeted services off bootstrap globals', () => { const serviceFiles = [ 'events.ts', + 'ipc/panels.ts', 'services/panelManager.ts', 'services/terminalPanelManager.ts', 'services/terminalSessionManager.ts', @@ -35,6 +36,21 @@ describe('daemon/client import boundary', () => { } }); + it('keeps headless bootstrap paths off the desktop entrypoint', () => { + const boundaryFiles = [ + 'daemon/bootstrap.ts', + 'daemon/headless.ts', + 'ipc/panels.ts', + 'services/resourceMonitorService.ts', + ]; + + for (const relativePath of boundaryFiles) { + const source = readMainSrcFile(relativePath); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + } + }); + it('routes targeted renderer sends through the event sink adapter', () => { const eventFiles = [ 'events.ts', @@ -94,4 +110,9 @@ describe('daemon/client import boundary', () => { expect(source).toContain(`'${channel}'`); } }); + + it('keeps task queue environment selection free of Electron globals', () => { + const source = readMainSrcFile('services/taskQueue.ts'); + expect(source).not.toContain('process.versions.electron'); + }); }); diff --git a/main/src/daemon/bootstrap.ts b/main/src/daemon/bootstrap.ts new file mode 100644 index 0000000..b22d762 --- /dev/null +++ b/main/src/daemon/bootstrap.ts @@ -0,0 +1,266 @@ +import { powerMonitor, type App, type BrowserWindow } from 'electron'; +import { startupRetentionResult } from '../services/database'; +import { ConfigManager } from '../services/configManager'; +import { Logger } from '../utils/logger'; +import { DatabaseService } from '../database/database'; +import { AnalyticsManager } from '../services/analyticsManager'; +import { SessionManager } from '../services/sessionManager'; +import { ArchiveProgressManager } from '../services/archiveProgressManager'; +import { SpotlightManager } from '../services/spotlightManager'; +import { PermissionIpcServer } from '../services/permissionIpcServer'; +import { WorktreeManager } from '../services/worktreeManager'; +import { CliManagerFactory } from '../services/cliManagerFactory'; +import type { AbstractCliManager } from '../services/panels/cli/AbstractCliManager'; +import { GitDiffManager } from '../services/gitDiffManager'; +import { GitStatusManager } from '../services/gitStatusManager'; +import { ExecutionTracker } from '../services/executionTracker'; +import { WorktreeNameGenerator } from '../services/worktreeNameGenerator'; +import { RunCommandManager } from '../services/runCommandManager'; +import { VersionChecker } from '../services/versionChecker'; +import { TaskQueue } from '../services/taskQueue'; +import { registerIpcHandlers } from '../ipc'; +import { PaneDaemonServer } from './server'; +import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from '../core/eventSink'; +import { + setPaneRuntime, + type PaneWebviewContext, + type PtyHostRuntime, +} from '../core/runtime'; +import type { AppServices, DaemonHostServices } from '../ipc/types'; +import { setupEventListeners } from '../events'; +import { getAppDirectory } from '../utils/appDirectory'; +import { resourceMonitorService } from '../services/resourceMonitorService'; +import type { PaneCommandRegistry } from './commandRegistry'; + +interface PaneDaemonHostOptions { + app: App; + getMainWindow: () => BrowserWindow | null; + getPtyHostRuntime: () => PtyHostRuntime | null; + getWebviewContextMap?: () => Map; + rendererEventSink?: PaneEventSink; + mode?: 'desktop' | 'headless'; + restoreSpotlights?: boolean; +} + +export interface PaneDaemonHost { + services: AppServices; + daemonServices: DaemonHostServices; + commandRegistry: PaneCommandRegistry; + paneDaemonServer: PaneDaemonServer | null; + permissionIpcServer: PermissionIpcServer | null; + shutdown(): Promise; +} + +let powerMonitorDiagnosticsRegistered = false; + +function installPaneRuntime( + eventSink: PaneEventSink, + configManager: ConfigManager, + getPtyHostRuntime: () => PtyHostRuntime | null, + getWebviewContextMap: () => Map, + daemonEventSink?: PaneEventSink, +): void { + setPaneRuntime({ + eventSink, + daemonEventSink, + getConfigManager: () => configManager, + getPtyHostRuntime, + getWebviewContextMap, + }); +} + +function registerPowerMonitorDiagnostics(logger: Logger): void { + if (powerMonitorDiagnosticsRegistered) { + return; + } + + powerMonitorDiagnosticsRegistered = true; + powerMonitor.on('suspend', () => logger.info('[Lifecycle] power:suspend')); + powerMonitor.on('resume', () => logger.info('[Lifecycle] power:resume')); + powerMonitor.on('lock-screen', () => logger.info('[Lifecycle] power:lock-screen')); + powerMonitor.on('unlock-screen', () => logger.info('[Lifecycle] power:unlock-screen')); +} + +export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Promise { + const mode = options.mode ?? 'desktop'; + const rendererEventSink = options.rendererEventSink ?? noopPaneEventSink; + const headlessWebviewContextMap = new Map(); + const getWebviewContextMap = options.getWebviewContextMap ?? (() => headlessWebviewContextMap); + + const configManager = new ConfigManager(); + await configManager.initialize(); + installPaneRuntime(rendererEventSink, configManager, options.getPtyHostRuntime, getWebviewContextMap); + + const logger = new Logger(configManager); + console.log('[Main] Logger initialized with file logging to ~/.pane/logs'); + registerPowerMonitorDiagnostics(logger); + + if (startupRetentionResult.error) { + logger.error('[ScrollbackRetention] Sweep failed', startupRetentionResult.error); + } else if (startupRetentionResult.result && startupRetentionResult.result.panelsCleared > 0) { + const result = startupRetentionResult.result; + logger.info( + `[ScrollbackRetention] Cleared ${result.panelsCleared} panels across ` + + `${result.sessionsTouched} sessions, freed ~${(result.bytesFreed / 1_000_000).toFixed(1)} MB`, + ); + } + + const dbPath = configManager.getDatabasePath(); + const databaseService = new DatabaseService(dbPath); + databaseService.initialize(); + + const analyticsManager = new AnalyticsManager(configManager); + const sessionManager = new SessionManager(databaseService, analyticsManager); + sessionManager.initializeFromDatabase(); + + if (process.platform === 'win32') { + const wslDistros = databaseService.getAllProjects() + .filter((project) => project.wsl_enabled && project.wsl_distribution) + .map((project) => project.wsl_distribution!); + if (wslDistros.length > 0) { + void import('../utils/wslUtils').then(({ bumpWSLInotifyLimits }) => + bumpWSLInotifyLimits(wslDistros).catch(() => {}), + ); + } + } + + const archiveProgressManager = new ArchiveProgressManager(); + const spotlightManager = new SpotlightManager(sessionManager, logger, options.getMainWindow); + + console.log('[Main] Initializing Permission IPC server...'); + let permissionIpcServer: PermissionIpcServer | null = new PermissionIpcServer(); + console.log('[Main] Starting Permission IPC server...'); + let permissionIpcPath: string | null = null; + + try { + await permissionIpcServer.start(); + permissionIpcPath = permissionIpcServer.getSocketPath(); + console.log('[Main] Permission IPC server started successfully'); + console.log('[Main] Permission IPC socket path:', permissionIpcPath); + } catch (error) { + console.error('[Main] Failed to start Permission IPC server:', error); + console.error('[Main] Permission-based MCP will be disabled'); + permissionIpcServer = null; + } + + const worktreeManager = new WorktreeManager(configManager, analyticsManager); + const activeProject = sessionManager.getActiveProject(); + if (activeProject) { + const context = sessionManager.getProjectContextByProjectId(activeProject.id); + if (context) { + await worktreeManager.initializeProject(activeProject.path, undefined, context.pathResolver, context.commandRunner); + } + } + + const cliManagerFactory = CliManagerFactory.getInstance(logger, configManager); + const defaultCliManager: AbstractCliManager = await cliManagerFactory.createManager('claude', { + sessionManager, + logger, + configManager, + additionalOptions: { permissionIpcPath }, + skipValidation: true, + }); + const gitDiffManager = new GitDiffManager(logger, analyticsManager); + const gitStatusManager = new GitStatusManager(sessionManager, worktreeManager, gitDiffManager, logger); + const executionTracker = new ExecutionTracker(sessionManager, gitDiffManager); + const worktreeNameGenerator = new WorktreeNameGenerator(configManager); + const runCommandManager = new RunCommandManager(databaseService); + const versionChecker = new VersionChecker(configManager, logger); + const taskQueue = new TaskQueue({ + sessionManager, + worktreeManager, + claudeCodeManager: defaultCliManager, + gitDiffManager, + executionTracker, + worktreeNameGenerator, + }); + + const daemonServices: DaemonHostServices = { + configManager, + databaseService, + sessionManager, + worktreeManager, + cliManagerFactory, + claudeCodeManager: defaultCliManager, + gitDiffManager, + gitStatusManager, + executionTracker, + worktreeNameGenerator, + runCommandManager, + versionChecker, + taskQueue, + getMainWindow: options.getMainWindow, + logger, + archiveProgressManager, + analyticsManager, + spotlightManager, + }; + + const services: AppServices = { + app: options.app, + ...daemonServices, + }; + + const commandRegistry = registerIpcHandlers(services); + + let paneDaemonServer: PaneDaemonServer | null = null; + try { + paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory()); + await paneDaemonServer.start(); + installPaneRuntime( + createFanoutEventSink([rendererEventSink, paneDaemonServer.getEventSink()]), + configManager, + options.getPtyHostRuntime, + getWebviewContextMap, + paneDaemonServer.getEventSink(), + ); + } catch (error) { + console.error('[Pane daemon] Failed to start local daemon server; continuing with renderer-only runtime events', error); + } + + setupEventListeners(services); + + const { logsManager } = await import('../services/panels/logPanel/logsManager'); + logsManager.setAnalyticsManager(analyticsManager); + + gitStatusManager.startPolling(); + if (mode === 'desktop') { + versionChecker.startPeriodicCheck(); + } + resourceMonitorService.initialize({ + app: options.app, + getSessionById: (sessionId) => sessionManager.getSession(sessionId), + }); + + if (options.restoreSpotlights !== false) { + try { + spotlightManager.restoreAll(); + } catch (error) { + console.error('[Main] Failed to restore spotlight state:', error); + } + } + + return { + services, + daemonServices, + commandRegistry, + paneDaemonServer, + permissionIpcServer, + async shutdown(): Promise { + resourceMonitorService.stop(); + spotlightManager.disableAll(); + await sessionManager.cleanup(); + await runCommandManager.stopAllRunCommands(); + gitStatusManager.stopPolling(); + configManager.stopWatching(); + await cliManagerFactory.shutdown(); + await taskQueue.close(); + await permissionIpcServer?.stop(); + if (paneDaemonServer) { + await paneDaemonServer.stop(); + } + versionChecker.stopPeriodicCheck(); + logger.close(); + }, + }; +} diff --git a/main/src/daemon/headless.ts b/main/src/daemon/headless.ts new file mode 100644 index 0000000..b88a451 --- /dev/null +++ b/main/src/daemon/headless.ts @@ -0,0 +1,61 @@ +import '../polyfills/readablestream'; +import { app } from 'electron'; +import { createPaneDaemonHost, type PaneDaemonHost } from './bootstrap'; +import { applyAppDirectoryOverrideFromArgs, getAppDirectory, migrateDataDirectory } from '../utils/appDirectory'; +import { setupConsoleWrapper } from '../utils/consoleWrapper'; + +let daemonHost: PaneDaemonHost | null = null; +let shutdownInProgress = false; + +const overrideDir = applyAppDirectoryOverrideFromArgs(); +if (overrideDir) { + console.log(`[Pane daemon] Using custom Pane directory: ${overrideDir}`); +} + +migrateDataDirectory(); +setupConsoleWrapper(); + +if (process.platform === 'darwin') { + app.dock?.hide(); +} + +async function shutdown(exitCode: number): Promise { + if (shutdownInProgress) { + return; + } + + shutdownInProgress = true; + try { + await daemonHost?.shutdown(); + } finally { + process.exit(exitCode); + } +} + +app.whenReady().then(async () => { + daemonHost = await createPaneDaemonHost({ + app, + getMainWindow: () => null, + getPtyHostRuntime: () => null, + mode: 'headless', + restoreSpotlights: false, + }); + + const endpoint = daemonHost.paneDaemonServer?.getEndpoint(); + if (endpoint) { + console.log(`[Pane daemon] Headless host ready on ${endpoint.transport}:${endpoint.path}`); + } else { + console.log(`[Pane daemon] Headless host ready in ${getAppDirectory()} (local daemon endpoint unavailable)`); + } +}).catch(async (error) => { + console.error('[Pane daemon] Failed to start headless host:', error); + await shutdown(1); +}); + +process.on('SIGINT', () => { + void shutdown(0); +}); + +process.on('SIGTERM', () => { + void shutdown(0); +}); diff --git a/main/src/index.ts b/main/src/index.ts index 9189da9..b3aa7cd 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -18,39 +18,26 @@ if (process.platform === 'win32') { } // Now import the rest of electron -import { BrowserWindow, Menu, ipcMain, shell, dialog, IpcMainInvokeEvent, session, WebContents, webContents, WebContentsView, powerMonitor } from 'electron'; +import { BrowserWindow, Menu, ipcMain, shell, dialog, IpcMainInvokeEvent, session, WebContents, webContents, WebContentsView } from 'electron'; import * as path from 'path'; import * as os from 'os'; -import { TaskQueue } from './services/taskQueue'; -import { SessionManager } from './services/sessionManager'; -import { ConfigManager } from './services/configManager'; -import { WorktreeManager } from './services/worktreeManager'; -import { WorktreeNameGenerator } from './services/worktreeNameGenerator'; -import { GitDiffManager } from './services/gitDiffManager'; -import { GitStatusManager } from './services/gitStatusManager'; -import { ExecutionTracker } from './services/executionTracker'; -import { DatabaseService } from './database/database'; -import { RunCommandManager } from './services/runCommandManager'; -import { PermissionIpcServer } from './services/permissionIpcServer'; -import { VersionChecker } from './services/versionChecker'; -import { Logger } from './utils/logger'; -import { ArchiveProgressManager } from './services/archiveProgressManager'; -import { AnalyticsManager } from './services/analyticsManager'; +import type { SessionManager } from './services/sessionManager'; +import type { ConfigManager } from './services/configManager'; +import type { WorktreeManager } from './services/worktreeManager'; +import type { GitStatusManager } from './services/gitStatusManager'; +import type { DatabaseService } from './database/database'; +import type { RunCommandManager } from './services/runCommandManager'; +import type { VersionChecker } from './services/versionChecker'; +import type { Logger } from './utils/logger'; +import type { ArchiveProgressManager } from './services/archiveProgressManager'; +import type { AnalyticsManager } from './services/analyticsManager'; import { resolveAnalyticsIdentity } from './services/analyticsIdentity'; -import { SpotlightManager } from './services/spotlightManager'; -import { startupRetentionResult } from './services/database'; import { resourceMonitorService } from './services/resourceMonitorService'; -import { setAppDirectory, migrateDataDirectory, getAppDirectory } from './utils/appDirectory'; +import { applyAppDirectoryOverrideFromArgs, migrateDataDirectory, getAppDirectory } from './utils/appDirectory'; import { getCurrentWorktreeName } from './utils/worktreeUtils'; -import { registerIpcHandlers } from './ipc'; import { setupAutoUpdater } from './autoUpdater'; -import { setupEventListeners } from './events'; -import { createFanoutEventSink, type PaneEventSink } from './core/eventSink'; -import { setPaneRuntime } from './core/runtime'; -import { AppServices } from './ipc/types'; import { getCloudVmManager } from './ipc/cloud'; -import { CliManagerFactory } from './services/cliManagerFactory'; -import { AbstractCliManager } from './services/panels/cli/AbstractCliManager'; +import type { CliManagerFactory } from './services/cliManagerFactory'; import { setupConsoleWrapper } from './utils/consoleWrapper'; import * as fs from 'fs'; import { terminalPanelManager } from './services/terminalPanelManager'; @@ -58,7 +45,7 @@ import { panelManager } from './services/panelManager'; import { TerminalPanelState } from '../../shared/types/panels'; import { worktreePoolManager } from './services/worktreePoolManager'; import { PtyHostSupervisor } from './ptyHost/ptyHostSupervisor'; -import { PaneDaemonServer } from './daemon/server'; +import { createPaneDaemonHost, type PaneDaemonHost } from './daemon/bootstrap'; export let mainWindow: BrowserWindow | null = null; @@ -66,31 +53,9 @@ export let mainWindow: BrowserWindow | null = null; // Populated by browser-panel:register-webview IPC, consumed by did-attach-webview handler. export const webviewContextMap = new Map(); -const electronPaneEventSink: PaneEventSink = { - send(channel, ...args) { - const window = mainWindow; - if (!window || window.isDestroyed()) { - return; - } - - window.webContents.send(channel, ...args); - }, -}; - -function installPaneRuntime(eventSink: PaneEventSink, daemonEventSink?: PaneEventSink): void { - setPaneRuntime({ - eventSink, - daemonEventSink, - getConfigManager: () => configManager, - getPtyHostRuntime: () => ptyHostSupervisor, - getWebviewContextMap: () => webviewContextMap, - }); -} - // Active DevTools WebContentsViews, keyed by the page webContentsId they inspect const activeDevToolsViews = new Map(); let devToolsHandlersRegistered = false; -let powerMonitorDiagnosticsRegistered = false; // Track partitions that already have the localhost header-stripping hook registered, // so we don't add duplicate listeners when multiple webviews share the same partition. @@ -140,16 +105,6 @@ function formatRendererDiagnostic(payload: RendererDiagnosticPayload): string { ].filter(Boolean).join(' '); } -function registerPowerMonitorDiagnostics(): void { - if (powerMonitorDiagnosticsRegistered) return; - powerMonitorDiagnosticsRegistered = true; - - powerMonitor.on('suspend', () => logger?.info('[Lifecycle] power:suspend')); - powerMonitor.on('resume', () => logger?.info('[Lifecycle] power:resume')); - powerMonitor.on('lock-screen', () => logger?.info('[Lifecycle] power:lock-screen')); - powerMonitor.on('unlock-screen', () => logger?.info('[Lifecycle] power:unlock-screen')); -} - /** * Set the application title based on development mode and worktree */ @@ -172,27 +127,19 @@ function setAppTitle() { } return title; } -let taskQueue: TaskQueue | null = null; - // Service instances (configManager exported for shell preference access) export let configManager: ConfigManager; let logger: Logger; export let sessionManager: SessionManager; let worktreeManager: WorktreeManager; let cliManagerFactory: CliManagerFactory; -let defaultCliManager: AbstractCliManager; -let gitDiffManager: GitDiffManager; let gitStatusManager: GitStatusManager; -let executionTracker: ExecutionTracker; -let worktreeNameGenerator: WorktreeNameGenerator; let databaseService: DatabaseService; let runCommandManager: RunCommandManager; -let permissionIpcServer: PermissionIpcServer | null; -let paneDaemonServer: PaneDaemonServer | null = null; let versionChecker: VersionChecker; let archiveProgressManager: ArchiveProgressManager; let analyticsManager: AnalyticsManager; -let spotlightManager: SpotlightManager; +let paneDaemonHost: PaneDaemonHost | null = null; // ptyHost supervisor — forked as an Electron UtilityProcess on app ready, // but only when the `usePtyHost` setting is enabled (default: off). When @@ -240,33 +187,9 @@ if (isDevelopment) { // Set up console wrapper to reduce logging in production setupConsoleWrapper(); -// Parse command-line arguments for custom Pane directory -const args = process.argv.slice(2); -for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - // Support both --pane-dir=/path and --pane-dir /path formats - if (arg.startsWith('--pane-dir=')) { - const dir = arg.substring('--pane-dir='.length); - setAppDirectory(dir); - console.log(`[Main] Using custom Pane directory: ${dir}`); - } else if (arg === '--pane-dir' && i + 1 < args.length) { - const dir = args[i + 1]; - setAppDirectory(dir); - console.log(`[Main] Using custom Pane directory: ${dir}`); - i++; // Skip the next argument since we've consumed it - } - // Deprecated: support old --foozol-dir for backward compatibility - else if (arg.startsWith('--foozol-dir=')) { - const dir = arg.substring('--foozol-dir='.length); - setAppDirectory(dir); - console.log(`[Main] Using custom Pane directory (deprecated --foozol-dir): ${dir}`); - } else if (arg === '--foozol-dir' && i + 1 < args.length) { - const dir = args[i + 1]; - setAppDirectory(dir); - console.log(`[Main] Using custom Pane directory (deprecated --foozol-dir): ${dir}`); - i++; - } +const overrideDir = applyAppDirectoryOverrideFromArgs(); +if (overrideDir) { + console.log(`[Main] Using custom Pane directory: ${overrideDir}`); } // Migrate data directory from ~/.foozol to ~/.pane (one-time migration for existing users) @@ -970,37 +893,37 @@ async function createWindow() { } async function initializeServices() { - configManager = new ConfigManager(); - await configManager.initialize(); - installPaneRuntime(electronPaneEventSink); - - // Initialize logger early so it can capture all logs - logger = new Logger(configManager); - console.log('[Main] Logger initialized with file logging to ~/.pane/logs'); - registerPowerMonitorDiagnostics(); - - // Log the scrollback retention result captured at database module load. - // The sweep itself runs before panelManager's constructor caches rows into - // RAM (see services/database.ts), so by the time we reach here the DB is - // already trimmed and the panel cache is built from the trimmed state. - if (startupRetentionResult.error) { - logger.error('[ScrollbackRetention] Sweep failed', startupRetentionResult.error); - } else if (startupRetentionResult.result && startupRetentionResult.result.panelsCleared > 0) { - const r = startupRetentionResult.result; - logger.info( - `[ScrollbackRetention] Cleared ${r.panelsCleared} panels across ` + - `${r.sessionsTouched} sessions, freed ~${(r.bytesFreed / 1_000_000).toFixed(1)} MB` - ); - } + const electronPaneEventSink = { + send(channel: string, ...args: unknown[]) { + const window = mainWindow; + if (!window || window.isDestroyed()) { + return; + } + window.webContents.send(channel, ...args); + }, + }; - // Use the same database path as the original backend - const dbPath = configManager.getDatabasePath(); - databaseService = new DatabaseService(dbPath); - databaseService.initialize(); + paneDaemonHost = await createPaneDaemonHost({ + app, + getMainWindow: () => mainWindow, + getPtyHostRuntime: () => ptyHostSupervisor, + getWebviewContextMap: () => webviewContextMap, + rendererEventSink: electronPaneEventSink, + }); - // Initialize analytics manager early so it can be used by SessionManager - analyticsManager = new AnalyticsManager(configManager); + const services = paneDaemonHost.services; + configManager = services.configManager; + databaseService = services.databaseService; + sessionManager = services.sessionManager; + worktreeManager = services.worktreeManager; + cliManagerFactory = services.cliManagerFactory; + gitStatusManager = services.gitStatusManager; + runCommandManager = services.runCommandManager; + versionChecker = services.versionChecker; + logger = services.logger as Logger; + archiveProgressManager = services.archiveProgressManager as ArchiveProgressManager; + analyticsManager = services.analyticsManager as AnalyticsManager; ipcMain.handle('analytics:get-identity', async () => { try { @@ -1022,129 +945,6 @@ async function initializeServices() { } }); - // Set analytics manager on logsManager for script execution tracking - const { logsManager } = await import('./services/panels/logPanel/logsManager'); - logsManager.setAnalyticsManager(analyticsManager); - - sessionManager = new SessionManager(databaseService, analyticsManager); - sessionManager.initializeFromDatabase(); - - // Bump WSL inotify limits if any WSL projects exist (limits don't persist across WSL reboots) - if (process.platform === 'win32') { - const wslDistros = databaseService.getAllProjects() - .filter(p => p.wsl_enabled && p.wsl_distribution) - .map(p => p.wsl_distribution!); - if (wslDistros.length > 0) { - import('./utils/wslUtils').then(({ bumpWSLInotifyLimits }) => - bumpWSLInotifyLimits(wslDistros).catch(() => {}) - ); - } - } - - archiveProgressManager = new ArchiveProgressManager(); - - spotlightManager = new SpotlightManager(sessionManager, logger, () => mainWindow); - - // Start permission IPC server - console.log('[Main] Initializing Permission IPC server...'); - permissionIpcServer = new PermissionIpcServer(); - console.log('[Main] Starting Permission IPC server...'); - - let permissionIpcPath: string | null = null; - try { - await permissionIpcServer.start(); - permissionIpcPath = permissionIpcServer.getSocketPath(); - console.log('[Main] Permission IPC server started successfully'); - console.log('[Main] Permission IPC socket path:', permissionIpcPath); - } catch (error) { - console.error('[Main] Failed to start Permission IPC server:', error); - console.error('[Main] Permission-based MCP will be disabled'); - permissionIpcServer = null; - } - - // Create worktree manager with configManager and analyticsManager - worktreeManager = new WorktreeManager(configManager, analyticsManager); - - // Initialize the active project's worktree directory if one exists - const activeProject = sessionManager.getActiveProject(); - if (activeProject) { - const ctx = sessionManager.getProjectContextByProjectId(activeProject.id); - if (ctx) { - await worktreeManager.initializeProject(activeProject.path, undefined, ctx.pathResolver, ctx.commandRunner); - } - } - - // Initialize CLI manager factory - cliManagerFactory = CliManagerFactory.getInstance(logger, configManager); - - // Create default CLI manager (Claude) with permission IPC path - // Skip validation during startup - tools will be validated when actually used - defaultCliManager = await cliManagerFactory.createManager('claude', { - sessionManager, - logger, - configManager, - additionalOptions: { permissionIpcPath }, - skipValidation: true // Allow Pane to start even if Claude Code is not installed - }); - gitDiffManager = new GitDiffManager(logger, analyticsManager); - gitStatusManager = new GitStatusManager(sessionManager, worktreeManager, gitDiffManager, logger); - executionTracker = new ExecutionTracker(sessionManager, gitDiffManager); - worktreeNameGenerator = new WorktreeNameGenerator(configManager); - runCommandManager = new RunCommandManager(databaseService); - - // Initialize version checker - versionChecker = new VersionChecker(configManager, logger); - - taskQueue = new TaskQueue({ - sessionManager, - worktreeManager, - claudeCodeManager: defaultCliManager, // Use default CLI manager for backward compatibility - gitDiffManager, - executionTracker, - worktreeNameGenerator, - getMainWindow: () => mainWindow - }); - - const services: AppServices = { - app, - configManager, - databaseService, - sessionManager, - worktreeManager, - cliManagerFactory, - claudeCodeManager: defaultCliManager, // Backward compatibility - gitDiffManager, - gitStatusManager, - executionTracker, - worktreeNameGenerator, - runCommandManager, - versionChecker, - taskQueue, - getMainWindow: () => mainWindow, - logger, - archiveProgressManager, - analyticsManager, - spotlightManager, - }; - - // Initialize IPC handlers first so managers (like ClaudePanelManager) are ready - const commandRegistry = registerIpcHandlers(services); - - try { - paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory()); - await paneDaemonServer.start(); - installPaneRuntime(createFanoutEventSink([ - electronPaneEventSink, - paneDaemonServer.getEventSink(), - ]), paneDaemonServer.getEventSink()); - } catch (error) { - paneDaemonServer = null; - console.error('[Pane daemon] Failed to start local daemon server; continuing with Electron-only runtime events', error); - } - - // Then set up event listeners that may rely on initialized managers - setupEventListeners(services); - // Console log IPC handler. The preload console wrapper (dev-only) forwards // every renderer console call here for frontend-debug.log capture. Renderer // callers can also invoke this directly and set `toMainLog: true` to also @@ -1182,22 +982,6 @@ async function initializeServices() { logger.error(`[RendererFatal] ${formatRendererDiagnostic(payload || {})}`); return { success: true }; }); - - // Start periodic version checking (only if enabled in settings) - versionChecker.startPeriodicCheck(); - - // Start git status polling - gitStatusManager.startPolling(); - - // Start resource monitoring - resourceMonitorService.initialize(app); - - // Restore spotlight state from previous session - try { - spotlightManager.restoreAll(); - } catch (error) { - console.error('[Main] Failed to restore spotlight state:', error); - } } app.whenReady().then(async () => { @@ -1517,34 +1301,8 @@ app.on('before-quit', async (event) => { terminalPanelManager.destroyAllTerminals(); console.log('[Main] Terminal panel processes destroyed'); - // Phase 4: Normal cleanup (existing code) - // Disable all spotlights and restore repo roots - if (spotlightManager) { - console.log('[Main] Disabling all spotlights...'); - spotlightManager.disableAll(); - console.log('[Main] Spotlights disabled'); - } - - // Cleanup all sessions and terminate child processes - if (sessionManager) { - console.log('[Main] Cleaning up sessions and terminating child processes...'); - await sessionManager.cleanup(); - console.log('[Main] Session cleanup complete'); - } - - // Stop all run commands - if (runCommandManager) { - console.log('[Main] Stopping all run commands...'); - await runCommandManager.stopAllRunCommands(); - console.log('[Main] Run commands stopped'); - } - - // Stop git status polling - if (gitStatusManager) { - console.log('[Main] Stopping git status polling...'); - gitStatusManager.stopPolling(); - console.log('[Main] Git status polling stopped'); - } + // Phase 4: Host/runtime cleanup + console.log('[Main] Shutting down daemon host services...'); // Kill IAP tunnel if running const cloudManager = getCloudVmManager(); @@ -1555,40 +1313,10 @@ app.on('before-quit', async (event) => { console.log('[Main] Cloud tunnel stopped'); } - // Stop config file watcher - if (configManager) { - configManager.stopWatching(); - } - - // Shutdown CLI manager factory and all CLI processes - if (cliManagerFactory) { - console.log('[Main] Shutting down CLI manager factory and all CLI processes...'); - await cliManagerFactory.shutdown(); - console.log('[Main] CLI manager factory shutdown complete'); - } - - // Close task queue - if (taskQueue) { - await taskQueue.close(); - } - - // Stop permission IPC server - if (permissionIpcServer) { - console.log('[Main] Stopping permission IPC server...'); - await permissionIpcServer.stop(); - console.log('[Main] Permission IPC server stopped'); - } - - if (paneDaemonServer) { - console.log('[Main] Stopping Pane daemon server...'); - await paneDaemonServer.stop(); - paneDaemonServer = null; - console.log('[Main] Pane daemon server stopped'); - } - - // Stop version checker - if (versionChecker) { - versionChecker.stopPeriodicCheck(); + if (paneDaemonHost) { + await paneDaemonHost.shutdown(); + paneDaemonHost = null; + console.log('[Main] Daemon host services stopped'); } // Track app closed event with session duration. @@ -1631,11 +1359,6 @@ app.on('before-quit', async (event) => { } } - // Close logger to ensure all logs are flushed - if (logger) { - logger.close(); - } - const totalShutdownTime = Date.now() - shutdownStartTime; logToFile(`Graceful shutdown complete in ${Date.now() - shutdownStartTime}ms`); console.log(`[Main] Graceful shutdown complete in ${totalShutdownTime}ms`); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index 1daa4e0..302d465 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -6,7 +6,7 @@ import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; import type { PaneCommandRegistry } from '../daemon/commandRegistry'; -import { webviewContextMap } from '../index'; +import { getPaneWebviewContextMap } from '../core/runtime'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; import { databaseService } from '../services/database'; @@ -755,7 +755,7 @@ export function registerPanelHandlers( // Register a webview's panel/session context so the did-attach-webview popup handler // (in index.ts) can route popups to the correct browser panel. ipcMain.handle('browser-panel:register-webview', async (_, wcId: number, panelId: string, sessionId: string) => { - webviewContextMap.set(wcId, { panelId, sessionId }); + getPaneWebviewContextMap().set(wcId, { panelId, sessionId }); return { success: true }; }); diff --git a/main/src/ipc/types.ts b/main/src/ipc/types.ts index 196a188..5d1b4be 100644 --- a/main/src/ipc/types.ts +++ b/main/src/ipc/types.ts @@ -4,10 +4,13 @@ import type { TaskQueue } from '../services/taskQueue'; import type { AnalyticsManager } from '../services/analyticsManager'; import type { SpotlightManager } from '../services/spotlightManager'; -export interface AppServices extends CoreServices { - app: App; +export interface DaemonHostServices extends CoreServices { taskQueue: TaskQueue | null; getMainWindow: () => BrowserWindow | null; analyticsManager?: AnalyticsManager; spotlightManager: SpotlightManager; } + +export interface AppServices extends DaemonHostServices { + app: App; +} diff --git a/main/src/services/resourceMonitorService.test.ts b/main/src/services/resourceMonitorService.test.ts new file mode 100644 index 0000000..958623a --- /dev/null +++ b/main/src/services/resourceMonitorService.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { ResourceMonitorService } from './resourceMonitorService'; + +describe('ResourceMonitorService', () => { + it('returns no Electron metrics when initialized without an app', () => { + const service = new ResourceMonitorService(); + service.initialize(); + + expect((service as { getElectronMetrics(): unknown[] }).getElectronMetrics()).toEqual([]); + }); +}); diff --git a/main/src/services/resourceMonitorService.ts b/main/src/services/resourceMonitorService.ts index 8642199..b27d4bd 100644 --- a/main/src/services/resourceMonitorService.ts +++ b/main/src/services/resourceMonitorService.ts @@ -30,8 +30,14 @@ interface WindowsBatchItem { MemoryMB: number; } +interface ResourceMonitorInitializationOptions { + app?: App | null; + getSessionById?: (sessionId: string) => { name?: string; initial_prompt?: string } | undefined; +} + export class ResourceMonitorService extends EventEmitter { private app: App | null = null; + private getSessionById: ((sessionId: string) => { name?: string; initial_prompt?: string } | undefined) | null = null; private idleTimer: ReturnType | null = null; private activeTimer: ReturnType | null = null; private isActivePolling = false; @@ -40,8 +46,9 @@ export class ResourceMonitorService extends EventEmitter { private isHidden = false; private needsCpuWarmup = false; - initialize(app: App): void { - this.app = app; + initialize(options: ResourceMonitorInitializationOptions = {}): void { + this.app = options.app ?? null; + this.getSessionById = options.getSessionById ?? null; } private getElectronMetrics(): ElectronProcessInfo[] { @@ -240,8 +247,6 @@ export class ResourceMonitorService extends EventEmitter { // eslint-disable-next-line @typescript-eslint/no-require-imports const { terminalPanelManager } = require('./terminalPanelManager') as { terminalPanelManager: { getSessionPids(): Map } }; // eslint-disable-next-line @typescript-eslint/no-require-imports - const { sessionManager } = require('../index') as { sessionManager: { getSession(id: string): { name?: string; initial_prompt?: string } | undefined } | null }; - // eslint-disable-next-line @typescript-eslint/no-require-imports const { CliToolRegistry } = require('./cliToolRegistry') as { CliToolRegistry: { getInstance(): { getAllManagers(): { getSessionPids(): Map }[] } } }; // Collect PIDs from all sources: terminal panels + CLI managers (Claude, Codex, etc.) @@ -270,7 +275,7 @@ export class ResourceMonitorService extends EventEmitter { const allTrackedPids = new Set(); for (const [sessionId, ptyPids] of sessionPids) { - const session = sessionManager?.getSession?.(sessionId); + const session = this.getSessionById?.(sessionId); const sessionName = session?.name || session?.initial_prompt?.slice(0, 30) || sessionId; const allPids: number[] = []; diff --git a/main/src/services/taskQueue.ts b/main/src/services/taskQueue.ts index 1a4a1c4..28d5014 100644 --- a/main/src/services/taskQueue.ts +++ b/main/src/services/taskQueue.ts @@ -26,7 +26,7 @@ interface TaskQueueOptions { gitDiffManager: GitDiffManager; executionTracker: ExecutionTracker; worktreeNameGenerator: WorktreeNameGenerator; - getMainWindow: () => Electron.BrowserWindow | null; + useSimpleQueue?: boolean; } interface CreateSessionJob { @@ -60,8 +60,9 @@ export class TaskQueue { constructor(private options: TaskQueueOptions) { console.log('[TaskQueue] Initializing task queue...'); - // Check if we're in Electron without Redis - this.useSimpleQueue = !process.env.REDIS_URL && typeof process.versions.electron !== 'undefined'; + // Headless daemon mode still needs the in-process queue when Redis is not + // configured, so queue selection cannot depend on Electron globals. + this.useSimpleQueue = options.useSimpleQueue ?? !process.env.REDIS_URL; // Determine concurrency based on platform // Linux has stricter PTY and file descriptor limits, so we reduce concurrency @@ -71,7 +72,7 @@ export class TaskQueue { console.log(`[TaskQueue] Platform: ${os.platform()}, Session concurrency: ${sessionConcurrency}`); if (this.useSimpleQueue) { - console.log('[TaskQueue] Using SimpleQueue for Electron environment'); + console.log('[TaskQueue] Using SimpleQueue for local in-process queue'); this.sessionQueue = new SimpleQueue('session-creation', sessionConcurrency); this.inputQueue = new SimpleQueue('session-input', 10); diff --git a/main/src/utils/appDirectory.test.ts b/main/src/utils/appDirectory.test.ts new file mode 100644 index 0000000..ebd0ee0 --- /dev/null +++ b/main/src/utils/appDirectory.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { getAppDirectoryOverrideFromArgs } from './appDirectory'; + +describe('appDirectory CLI parsing', () => { + it('parses pane-dir in both supported forms', () => { + expect(getAppDirectoryOverrideFromArgs(['--pane-dir=/tmp/pane-a'])).toBe('/tmp/pane-a'); + expect(getAppDirectoryOverrideFromArgs(['--pane-dir', '/tmp/pane-b'])).toBe('/tmp/pane-b'); + }); + + it('accepts the deprecated foozol-dir flags for backward compatibility', () => { + expect(getAppDirectoryOverrideFromArgs(['--foozol-dir=/tmp/pane-c'])).toBe('/tmp/pane-c'); + expect(getAppDirectoryOverrideFromArgs(['--foozol-dir', '/tmp/pane-d'])).toBe('/tmp/pane-d'); + }); + + it('returns undefined when no override flag is provided', () => { + expect(getAppDirectoryOverrideFromArgs(['--verbose'])).toBeUndefined(); + }); +}); diff --git a/main/src/utils/appDirectory.ts b/main/src/utils/appDirectory.ts index 0889a0e..ed5ec8c 100644 --- a/main/src/utils/appDirectory.ts +++ b/main/src/utils/appDirectory.ts @@ -1,12 +1,36 @@ import { homedir } from 'os'; import { join } from 'path'; import { existsSync, renameSync } from 'fs'; -import { app } from 'electron'; let customAppDir: string | undefined; -function getCliAppDirectory(): string | undefined { - const args = process.argv.slice(2); +interface ElectronAppLike { + isPackaged: boolean; + getPath(name: 'exe'): string; +} + +function getElectronApp(): ElectronAppLike | null { + try { + // In a plain Node process, `require('electron')` resolves to the Electron + // binary path string rather than the runtime module. Guard that case. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const electronModule = require('electron') as unknown; + if (!electronModule || typeof electronModule !== 'object') { + return null; + } + + const electronApp = (electronModule as { app?: ElectronAppLike }).app; + if (!electronApp || typeof electronApp.getPath !== 'function') { + return null; + } + + return electronApp; + } catch { + return null; + } +} + +export function getAppDirectoryOverrideFromArgs(args = process.argv.slice(2)): string | undefined { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg.startsWith('--pane-dir=')) { @@ -25,6 +49,15 @@ function getCliAppDirectory(): string | undefined { return undefined; } +export function applyAppDirectoryOverrideFromArgs(args = process.argv.slice(2)): string | undefined { + const override = getAppDirectoryOverrideFromArgs(args); + if (override) { + setAppDirectory(override); + } + + return override; +} + /** * Sets a custom Pane directory path. This should be called early in the * application lifecycle, before any services are initialized. @@ -38,14 +71,16 @@ export function setAppDirectory(dir: string): void { * rather than a development build */ function isInstalledApp(): boolean { + const electronApp = getElectronApp(); + // Check if app is packaged (built for distribution) - if (!app.isPackaged) { + if (!electronApp?.isPackaged) { return false; } // On macOS, check if running from /Applications or a mounted DMG volume if (process.platform === 'darwin') { - const appPath = app.getPath('exe'); + const appPath = electronApp.getPath('exe'); // Apps installed from DMG or in /Applications will have these paths const isInApplications = appPath.startsWith('/Applications/'); const isInVolumes = appPath.startsWith('/Volumes/'); @@ -71,7 +106,7 @@ export function getAppDirectory(): string { // 2. Check CLI app-dir flags. This must happen inside getAppDirectory() // because services/database is imported before index.ts can parse argv. - const cliDir = getCliAppDirectory(); + const cliDir = getAppDirectoryOverrideFromArgs(); if (cliDir) { return cliDir; } @@ -90,7 +125,8 @@ export function getAppDirectory(): string { // 5. If running inside Pane (detected by bundle identifier) in development, use development directory // This prevents development Pane from interfering with production Pane - if (process.env.__CFBundleIdentifier === 'com.dcouple.pane' && !app.isPackaged) { + const electronApp = getElectronApp(); + if (process.env.__CFBundleIdentifier === 'com.dcouple.pane' && !electronApp?.isPackaged) { console.log('[Pane] Detected running inside Pane development, using ~/.pane_dev for isolation'); return join(homedir(), '.pane_dev'); } @@ -106,7 +142,7 @@ export function getAppDirectory(): string { export function migrateDataDirectory(): void { // Skip migration if a custom directory is set (via --pane-dir, --foozol-dir, or env vars) // to avoid moving ~/.foozol out from under a running app that explicitly configured its path - if (customAppDir || getCliAppDirectory() || process.env.PANE_DIR || process.env.FOOZOL_DIR) { + if (customAppDir || getAppDirectoryOverrideFromArgs() || process.env.PANE_DIR || process.env.FOOZOL_DIR) { return; } diff --git a/package.json b/package.json index 2cc2a7c..3c09c80 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "scripts": { "dev": "node scripts/pane-run-script.js", + "daemon:headless": "pnpm run build:main && pnpm exec electron ./main/dist/main/src/daemon/headless.js", "electron-dev": "pnpm run build:main && concurrently \"pnpm run --filter main dev\" \"pnpm run --filter frontend dev\" \"wait-on http-get://localhost:${VITE_PORT:-${PORT:-4521}} && electron .\"", "electron-dev:custom": "concurrently \"pnpm run --filter frontend dev\" \"wait-on http-get://localhost:${VITE_PORT:-${PORT:-4521}} && electron .\"", "build": "pnpm run build:frontend && pnpm run build:main && pnpm run build:electron",