From 7fd070d7e4c51390356a659d822622594eb7531b Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 11:38:13 -0700 Subject: [PATCH 1/2] Extract runtime and event sink boundary --- main/src/core/eventSink.test.ts | 35 ++++ main/src/core/eventSink.ts | 43 +++++ main/src/core/runtime.test.ts | 45 +++++ main/src/core/runtime.ts | 86 ++++++++++ main/src/core/services.ts | 35 ++++ main/src/events.ts | 156 ++++-------------- main/src/index.ts | 21 ++- main/src/ipc/types.ts | 34 +--- main/src/services/panelManager.ts | 29 ++-- .../services/panels/cli/AbstractCliManager.ts | 9 +- .../services/panels/logPanel/logsManager.ts | 100 ++++++----- main/src/services/runCommandManager.ts | 16 +- main/src/services/scriptExecutionTracker.ts | 18 +- main/src/services/sessionManager.ts | 4 +- main/src/services/taskQueue.ts | 4 +- main/src/services/terminalPanelManager.ts | 92 +++++------ main/src/services/terminalSessionManager.ts | 14 +- 17 files changed, 434 insertions(+), 307 deletions(-) create mode 100644 main/src/core/eventSink.test.ts create mode 100644 main/src/core/eventSink.ts create mode 100644 main/src/core/runtime.test.ts create mode 100644 main/src/core/runtime.ts create mode 100644 main/src/core/services.ts diff --git a/main/src/core/eventSink.test.ts b/main/src/core/eventSink.test.ts new file mode 100644 index 00000000..7a641364 --- /dev/null +++ b/main/src/core/eventSink.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from './eventSink'; + +describe('PaneEventSink', () => { + it('noopPaneEventSink ignores sends', () => { + expect(() => noopPaneEventSink.send('session:created', { id: 'session-1' })).not.toThrow(); + }); + + it('fans out channel payloads to every sink', () => { + const sinkA = { send: vi.fn() } satisfies PaneEventSink; + const sinkB = { send: vi.fn() } satisfies PaneEventSink; + + const sink = createFanoutEventSink([sinkA, sinkB]); + const payload = { id: 'panel-1' }; + + sink.send('panel:created', payload, 'extra'); + + expect(sinkA.send).toHaveBeenCalledWith('panel:created', payload, 'extra'); + expect(sinkB.send).toHaveBeenCalledWith('panel:created', payload, 'extra'); + }); + + it('continues delivering after one sink throws', () => { + const failingSink = { + send: vi.fn(() => { + throw new Error('sink failed'); + }), + } satisfies PaneEventSink; + const healthySink = { send: vi.fn() } satisfies PaneEventSink; + + const sink = createFanoutEventSink([failingSink, healthySink]); + + expect(() => sink.send('terminal:output', { panelId: 'panel-1' })).toThrow('sink failed'); + expect(healthySink.send).toHaveBeenCalledWith('terminal:output', { panelId: 'panel-1' }); + }); +}); diff --git a/main/src/core/eventSink.ts b/main/src/core/eventSink.ts new file mode 100644 index 00000000..d0db5930 --- /dev/null +++ b/main/src/core/eventSink.ts @@ -0,0 +1,43 @@ +/** + * Daemon-owned event delivery contract. + * + * The current Electron app will back this with `webContents.send(...)`, but the + * contract is intentionally transport-agnostic so a future local socket, + * WebSocket, or relay-backed client can subscribe to the same runtime events. + * Auth, pairing, relay policy, and hosted VM lifecycle stay above this seam. + */ +export interface PaneEventSink { + send(channel: string, ...args: unknown[]): void; +} + +/** + * Safe default sink for tests and boot phases that should not emit renderer + * events yet. + */ +export const noopPaneEventSink: PaneEventSink = { + send: () => undefined, +}; + +/** + * Fan events out to multiple clients. One client error should not prevent + * delivery to the rest of the connected sinks. + */ +export function createFanoutEventSink(sinks: readonly PaneEventSink[]): PaneEventSink { + return { + send(channel: string, ...args: unknown[]) { + let firstError: unknown; + + for (const sink of sinks) { + try { + sink.send(channel, ...args); + } catch (error) { + firstError ??= error; + } + } + + if (firstError) { + throw firstError; + } + }, + }; +} diff --git a/main/src/core/runtime.test.ts b/main/src/core/runtime.test.ts new file mode 100644 index 00000000..3c177da6 --- /dev/null +++ b/main/src/core/runtime.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import type { ConfigManager } from '../services/configManager'; +import { + getPaneEventSink, + getPaneRuntime, + getPaneWebviewContextMap, + getPtyHostRuntime, + getRuntimeConfigManager, + resetPaneRuntimeForTests, + setPaneRuntime, + type PaneRuntime, +} from './runtime'; + +describe('pane runtime', () => { + afterEach(() => { + resetPaneRuntimeForTests(); + }); + + it('throws until runtime has been initialized', () => { + expect(() => getPaneRuntime()).toThrow('Pane runtime has not been initialized'); + expect(() => getRuntimeConfigManager()).toThrow('Pane runtime has not been initialized'); + expect(() => getPaneWebviewContextMap()).toThrow('Pane runtime has not been initialized'); + }); + + it('returns the installed runtime and helper accessors', () => { + const configManager = { source: 'test' } as unknown as ConfigManager; + const webviewContextMap = new Map([[1, { panelId: 'panel-1', sessionId: 'session-1' }]]); + const runtime: PaneRuntime = { + eventSink: { + send: () => undefined, + }, + getConfigManager: () => configManager, + getPtyHostRuntime: () => null, + getWebviewContextMap: () => webviewContextMap, + }; + + setPaneRuntime(runtime); + + expect(getPaneRuntime()).toBe(runtime); + expect(getPaneEventSink()).toBe(runtime.eventSink); + expect(getRuntimeConfigManager()).toBe(configManager); + expect(getPtyHostRuntime()).toBeNull(); + expect(getPaneWebviewContextMap()).toBe(webviewContextMap); + }); +}); diff --git a/main/src/core/runtime.ts b/main/src/core/runtime.ts new file mode 100644 index 00000000..2081e49d --- /dev/null +++ b/main/src/core/runtime.ts @@ -0,0 +1,86 @@ +import type { ConfigManager } from '../services/configManager'; +import type { PtyHostSpawnOpts } from '../ptyHost/types'; +import { noopPaneEventSink, type PaneEventSink } from './eventSink'; + +export interface PaneWebviewContext { + panelId: string; + sessionId: string; +} + +export interface PtyHandleLike { + readonly id: string; + readonly pid: number; + onData(listener: (data: string) => void): { dispose(): void }; + onExit(listener: (exitCode: number | null, signal: number | null) => void): { dispose(): void }; + write(data: string): Promise; + resize(cols: number, rows: number): Promise; + kill(signal?: NodeJS.Signals): Promise; + pause(): Promise; + resume(): Promise; +} + +/** + * Narrow PTY host contract consumed by daemon-owned services. + * + * This intentionally omits Electron window attachment and other client-facing + * concerns. Those stay in the Electron adapter around the runtime. + */ +export interface PtyHostRuntime { + spawn(opts: PtyHostSpawnOpts): Promise<{ ptyId: string; pid: number }>; + write(ptyId: string, data: string): Promise; + resize(ptyId: string, cols: number, rows: number): Promise; + kill(ptyId: string, signal?: NodeJS.Signals): Promise; + ack(ptyId: string, bytes: number): Promise; + pause(ptyId: string): Promise; + resume(ptyId: string): Promise; + getHandle(ptyId: string): PtyHandleLike | undefined; + postDataToRenderers(ptyId: string, data: string): void; +} + +/** + * Daemon runtime dependencies installed by the Electron bootstrap today and by + * a future headless daemon bootstrap later. + * + * This layer is intentionally local-only in Phase 1. Network listeners, + * authentication, pairing, relays, and hosted VM orchestration attach later. + */ +export interface PaneRuntime { + eventSink: PaneEventSink; + getConfigManager(): ConfigManager; + getPtyHostRuntime(): PtyHostRuntime | null; + getWebviewContextMap(): Map; +} + +let paneRuntime: PaneRuntime | null = null; + +export function setPaneRuntime(runtime: PaneRuntime): void { + paneRuntime = runtime; +} + +export function getPaneRuntime(): PaneRuntime { + if (!paneRuntime) { + throw new Error('Pane runtime has not been initialized'); + } + + return paneRuntime; +} + +export function getPaneEventSink(): PaneEventSink { + return paneRuntime?.eventSink ?? noopPaneEventSink; +} + +export function getRuntimeConfigManager(): ConfigManager { + return getPaneRuntime().getConfigManager(); +} + +export function getPtyHostRuntime(): PtyHostRuntime | null { + return getPaneRuntime().getPtyHostRuntime(); +} + +export function getPaneWebviewContextMap(): Map { + return getPaneRuntime().getWebviewContextMap(); +} + +export function resetPaneRuntimeForTests(): void { + paneRuntime = null; +} diff --git a/main/src/core/services.ts b/main/src/core/services.ts new file mode 100644 index 00000000..2079ec9f --- /dev/null +++ b/main/src/core/services.ts @@ -0,0 +1,35 @@ +import type { DatabaseService } from '../database/database'; +import type { ConfigManager } from '../services/configManager'; +import type { SessionManager } from '../services/sessionManager'; +import type { WorktreeManager } from '../services/worktreeManager'; +import type { CliManagerFactory } from '../services/cliManagerFactory'; +import type { AbstractCliManager } from '../services/panels/cli/AbstractCliManager'; +import type { GitDiffManager } from '../services/gitDiffManager'; +import type { GitStatusManager } from '../services/gitStatusManager'; +import type { ExecutionTracker } from '../services/executionTracker'; +import type { WorktreeNameGenerator } from '../services/worktreeNameGenerator'; +import type { RunCommandManager } from '../services/runCommandManager'; +import type { VersionChecker } from '../services/versionChecker'; +import type { Logger } from '../utils/logger'; +import type { ArchiveProgressManager } from '../services/archiveProgressManager'; + +/** + * Daemon-neutral service graph. Electron-only dependencies are intentionally + * excluded so the same runtime can later be hosted by a headless daemon. + */ +export interface CoreServices { + configManager: ConfigManager; + databaseService: DatabaseService; + sessionManager: SessionManager; + worktreeManager: WorktreeManager; + cliManagerFactory: CliManagerFactory; + claudeCodeManager: AbstractCliManager; + gitDiffManager: GitDiffManager; + gitStatusManager: GitStatusManager; + executionTracker: ExecutionTracker; + worktreeNameGenerator: WorktreeNameGenerator; + runCommandManager: RunCommandManager; + versionChecker: VersionChecker; + logger?: Logger; + archiveProgressManager?: ArchiveProgressManager; +} diff --git a/main/src/events.ts b/main/src/events.ts index 4581e3b9..f86003cb 100644 --- a/main/src/events.ts +++ b/main/src/events.ts @@ -1,5 +1,5 @@ -import type { BrowserWindow } from 'electron'; import { execSync } from './utils/commandExecutor'; +import { getPaneEventSink } from './core/runtime'; import type { AppServices } from './ipc/types'; import type { VersionInfo } from './services/versionChecker'; import { addSessionLog } from './ipc/logs'; @@ -22,7 +22,15 @@ function isArchivedSessionOutputValidation(validation: { error?: string; session ); } -export function setupEventListeners(services: AppServices, getMainWindow: () => BrowserWindow | null): void { +function sendRendererEvent(channel: string, ...args: unknown[]): void { + try { + getPaneEventSink().send(channel, ...args); + } catch (error) { + console.error(`[Main] Failed to send ${channel} event:`, error); + } +} + +export function setupEventListeners(services: AppServices): void { const { sessionManager, claudeCodeManager, @@ -43,10 +51,7 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => // Bridge resource monitor events to renderer resourceMonitorService.on('resource-update', (snapshot: unknown) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - mw.webContents.send('resource-monitor:update', snapshot); - } + sendRendererEvent('resource-monitor:update', snapshot); }); @@ -59,14 +64,7 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => // Listen to sessionManager events and broadcast to renderer sessionManager.on('session-created', async (session) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('session:created', session); - } catch (error) { - console.error('[Main] Failed to send session:created event:', error); - } - } + sendRendererEvent('session:created', session); // Auto-create a default terminal panel for every session try { @@ -93,51 +91,21 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => sessionManager.on('session-updated', (session) => { console.log(`[Main] session-updated event received for ${session.id} with status ${session.status}`); - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - console.log(`[Main] Sending session:updated to renderer for ${session.id}`); - try { - mw.webContents.send('session:updated', session); - } catch (error) { - console.error('[Main] Failed to send session:updated event:', error); - } - } else { - console.error(`[Main] Cannot send session:updated - mainWindow is ${mw ? 'destroyed' : 'null'}`); - } + console.log(`[Main] Sending session:updated to renderer for ${session.id}`); + sendRendererEvent('session:updated', session); }); sessionManager.on('session-deleted', (session) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('session:deleted', session); - } catch (error) { - console.error('[Main] Failed to send session:deleted event:', error); - } - } + sendRendererEvent('session:deleted', session); }); sessionManager.on('sessions-loaded', (sessions) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('sessions:loaded', sessions); - } catch (error) { - console.error('[Main] Failed to send sessions:loaded event:', error); - } - } + sendRendererEvent('sessions:loaded', sessions); }); sessionManager.on('zombie-processes-detected', (data) => { console.error('[Main] Zombie processes detected:', data); - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('zombie-processes-detected', data); - } catch (error) { - console.error('[Main] Failed to send zombie-processes-detected event:', error); - } - } + sendRendererEvent('zombie-processes-detected', data); }); sessionManager.on('session-output', (output) => { @@ -153,52 +121,29 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => return; // Don't broadcast invalid events } - const mw = getMainWindow(); - if (mw) { - mw.webContents.send('session:output', output); - } + sendRendererEvent('session:output', output); }); sessionManager.on('session-output-available', (info) => { - const mw = getMainWindow(); - if (mw) { - mw.webContents.send('session:output-available', info); - } + sendRendererEvent('session:output-available', info); }); // Listen for new prompts being added to panels sessionManager.on('panel-prompt-added', (data) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('panel:prompt-added', data); - } catch (error) { - console.error('[Main] Failed to send panel:prompt-added:', error); - } - } + sendRendererEvent('panel:prompt-added', data); }); // Listen for assistant responses being added to panels sessionManager.on('panel-response-added', (data) => { console.log('[Events] Received panel-response-added event for panel:', data.panelId); - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - console.log('[Events] Sending panel:response-added to renderer for panel:', data.panelId); - mw.webContents.send('panel:response-added', data); - } catch (error) { - console.error('[Main] Failed to send panel:response-added:', error); - } - } + console.log('[Events] Sending panel:response-added to renderer for panel:', data.panelId); + sendRendererEvent('panel:response-added', data); }); // Listen for project update events from sessionManager (since it extends EventEmitter) sessionManager.on('project:updated', (project: Project) => { console.log(`[Main] Project updated: ${project.id}`); - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - mw.webContents.send('project:updated', project); - } + sendRendererEvent('project:updated', project); }); // Listen to claudeCodeManager events @@ -244,13 +189,10 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => } // Send real-time updates to renderer - const mw = getMainWindow(); - if (mw) { - // Always send the output as-is, without formatting - // JSON messages will be formatted when loaded from the database via sessions:get-output - // This prevents duplicate formatted messages in the Output view - mw.webContents.send('session:output', output); - } + // Always send the output as-is, without formatting + // JSON messages will be formatted when loaded from the database via sessions:get-output + // This prevents duplicate formatted messages in the Output view + sendRendererEvent('session:output', output); }); claudeCodeManager.on('spawned', async ({ panelId, sessionId }: { panelId?: string; sessionId: string }) => { @@ -520,10 +462,7 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => // Listen to terminal output events (independent terminal, not run scripts) sessionManager.on('terminal-output', (output) => { // Broadcast terminal output to renderer - const mw = getMainWindow(); - if (mw) { - mw.webContents.send('terminal:output', output); - } + sendRendererEvent('terminal:output', output); }); // Listen to run command manager events (these should go to logs, not terminal) @@ -556,57 +495,30 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => runCommandManager.on('zombie-processes-detected', (data) => { console.error('[Main] Zombie processes detected from run command:', data); - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - mw.webContents.send('zombie-processes-detected', data); - } + sendRendererEvent('zombie-processes-detected', data); }); // Listen for version update events process.on('version-update-available', (versionInfo: VersionInfo) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - // Only send to renderer for custom dialog - no native dialogs - mw.webContents.send('version:update-available', versionInfo); - } + // Only send to renderer for custom dialog - no native dialogs + sendRendererEvent('version:update-available', versionInfo); }); // Listen to gitStatusManager events and broadcast to renderer // Only broadcast for active sessions or recent updates to reduce EventEmitter load gitStatusManager.on('git-status-updated', (sessionId: string, gitStatus: GitStatus) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('git-status-updated', { sessionId, gitStatus }); - } catch (error) { - console.error('[Main] Failed to send git-status-updated event:', error); - } - } + sendRendererEvent('git-status-updated', { sessionId, gitStatus }); }); // Listen for git status loading events gitStatusManager.on('git-status-loading', (sessionId: string) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('git-status-loading', { sessionId }); - } catch (error) { - console.error('[Main] Failed to send git-status-loading event:', error); - } - } + sendRendererEvent('git-status-loading', { sessionId }); }); // Listen for archive progress events if (archiveProgressManager) { archiveProgressManager.on('archive-progress', (progress) => { - const mw = getMainWindow(); - if (mw && !mw.isDestroyed()) { - try { - mw.webContents.send('archive:progress', progress); - } catch (error) { - console.error('[Main] Failed to send archive:progress event:', error); - } - } + sendRendererEvent('archive:progress', progress); }); } } diff --git a/main/src/index.ts b/main/src/index.ts index a273d749..6792e853 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -45,6 +45,8 @@ import { getCurrentWorktreeName } from './utils/worktreeUtils'; import { registerIpcHandlers } from './ipc'; import { setupAutoUpdater } from './autoUpdater'; import { setupEventListeners } from './events'; +import 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'; @@ -63,6 +65,17 @@ 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); + }, +}; + // Active DevTools WebContentsViews, keyed by the page webContentsId they inspect const activeDevToolsViews = new Map(); let devToolsHandlersRegistered = false; @@ -952,6 +965,12 @@ async function createWindow() { async function initializeServices() { configManager = new ConfigManager(); await configManager.initialize(); + setPaneRuntime({ + eventSink: electronPaneEventSink, + getConfigManager: () => configManager, + getPtyHostRuntime: () => ptyHostSupervisor, + getWebviewContextMap: () => webviewContextMap, + }); // Initialize logger early so it can capture all logs logger = new Logger(configManager); @@ -1109,7 +1128,7 @@ async function initializeServices() { // Initialize IPC handlers first so managers (like ClaudePanelManager) are ready registerIpcHandlers(services); // Then set up event listeners that may rely on initialized managers - setupEventListeners(services, () => mainWindow); + setupEventListeners(services); // Console log IPC handler. The preload console wrapper (dev-only) forwards // every renderer console call here for frontend-debug.log capture. Renderer diff --git a/main/src/ipc/types.ts b/main/src/ipc/types.ts index 5364a940..196a1887 100644 --- a/main/src/ipc/types.ts +++ b/main/src/ipc/types.ts @@ -1,41 +1,13 @@ import type { App, BrowserWindow } from 'electron'; +import type { CoreServices } from '../core/services'; import type { TaskQueue } from '../services/taskQueue'; -import type { SessionManager } from '../services/sessionManager'; -import type { ConfigManager } from '../services/configManager'; -import type { WorktreeManager } from '../services/worktreeManager'; -import type { WorktreeNameGenerator } from '../services/worktreeNameGenerator'; -import type { GitDiffManager } from '../services/gitDiffManager'; -import type { GitStatusManager } from '../services/gitStatusManager'; -import type { ExecutionTracker } from '../services/executionTracker'; -import type { DatabaseService } from '../database/database'; -import type { RunCommandManager } from '../services/runCommandManager'; -import type { VersionChecker } from '../services/versionChecker'; -import type { ClaudeCodeManager } from '../services/panels/claude/claudeCodeManager'; -import type { CliManagerFactory } from '../services/cliManagerFactory'; -import type { AbstractCliManager } from '../services/panels/cli/AbstractCliManager'; -import type { Logger } from '../utils/logger'; -import type { ArchiveProgressManager } from '../services/archiveProgressManager'; import type { AnalyticsManager } from '../services/analyticsManager'; import type { SpotlightManager } from '../services/spotlightManager'; -export interface AppServices { +export interface AppServices extends CoreServices { app: App; - configManager: ConfigManager; - databaseService: DatabaseService; - sessionManager: SessionManager; - worktreeManager: WorktreeManager; - cliManagerFactory: CliManagerFactory; - claudeCodeManager: AbstractCliManager; // Now uses abstract base class - gitDiffManager: GitDiffManager; - gitStatusManager: GitStatusManager; - executionTracker: ExecutionTracker; - worktreeNameGenerator: WorktreeNameGenerator; - runCommandManager: RunCommandManager; - versionChecker: VersionChecker; taskQueue: TaskQueue | null; getMainWindow: () => BrowserWindow | null; - logger?: Logger; - archiveProgressManager?: ArchiveProgressManager; analyticsManager?: AnalyticsManager; spotlightManager: SpotlightManager; -} \ No newline at end of file +} diff --git a/main/src/services/panelManager.ts b/main/src/services/panelManager.ts index 119f9c9f..5bfd8a95 100644 --- a/main/src/services/panelManager.ts +++ b/main/src/services/panelManager.ts @@ -1,8 +1,8 @@ import { v4 as uuidv4 } from 'uuid'; import { ToolPanel, CreatePanelRequest, PanelEventType, ToolPanelState, ToolPanelMetadata, ToolPanelType, LogsPanelState } from '../../../shared/types/panels'; +import { getPaneEventSink, getPaneWebviewContextMap } from '../core/runtime'; import { databaseService } from './database'; import { panelEventBus } from './panelEventBus'; -import { mainWindow, webviewContextMap } from '../index'; import { withLock } from '../utils/mutex'; import type { AnalyticsManager } from './analyticsManager'; @@ -21,6 +21,10 @@ export class PanelManager { this.analyticsManager = analyticsManager; } + private sendRendererEvent(channel: string, ...args: unknown[]): void { + getPaneEventSink().send(channel, ...args); + } + constructor() { // Load panels from database on startup (but don't initialize processes) this.loadPanelsFromDatabase(); @@ -128,9 +132,7 @@ export class PanelManager { }); // Emit IPC event to notify frontend - if (mainWindow) { - mainWindow.webContents.send('panel:created', panel); - } + this.sendRendererEvent('panel:created', panel); // Track terminal panel creation analytics (only for new panels, not restoration) if (request.type === 'terminal' && this.analyticsManager) { @@ -236,9 +238,7 @@ export class PanelManager { this.panels.delete(panelId); // Emit IPC event to notify frontend - if (mainWindow) { - mainWindow.webContents.send('panel:deleted', { panelId, sessionId: panel.sessionId }); - } + this.sendRendererEvent('panel:deleted', { panelId, sessionId: panel.sessionId }); // Track panel closure if (this.analyticsManager) { @@ -273,9 +273,7 @@ export class PanelManager { if (updates.metadata !== undefined) panel.metadata = updates.metadata; // Emit IPC event to notify frontend - if (mainWindow) { - mainWindow.webContents.send('panel:updated', panel); - } + this.sendRendererEvent('panel:updated', panel); console.log(`[PanelManager] Updated panel ${panelId}`); }); @@ -316,9 +314,7 @@ export class PanelManager { }); // Emit IPC event to notify frontend - if (mainWindow) { - mainWindow.webContents.send('panel:activeChanged', { sessionId, panelId }); - } + this.sendRendererEvent('panel:activeChanged', { sessionId, panelId }); // Track panel switching (only if both from and to panels exist) if (this.analyticsManager && fromPanelType && toPanelType && fromPanelType !== toPanelType) { @@ -433,9 +429,7 @@ export class PanelManager { panelEventBus.emitPanelEvent(event); // Also emit to frontend via IPC - if (mainWindow) { - mainWindow.webContents.send('panel:event', event); - } + this.sendRendererEvent('panel:event', event); console.log(`[PanelManager] Emitted event ${eventType} from panel ${panelId}`); } @@ -512,6 +506,7 @@ export class PanelManager { } // 5. Sweep webviewContextMap for entries owned by this session. + const webviewContextMap = getPaneWebviewContextMap(); for (const [wcId, ctx] of webviewContextMap.entries()) { if (ctx.sessionId === sessionId) { webviewContextMap.delete(wcId); @@ -545,4 +540,4 @@ export class PanelManager { } // Export singleton instance -export const panelManager = new PanelManager(); \ No newline at end of file +export const panelManager = new PanelManager(); diff --git a/main/src/services/panels/cli/AbstractCliManager.ts b/main/src/services/panels/cli/AbstractCliManager.ts index eaf37887..733f3912 100644 --- a/main/src/services/panels/cli/AbstractCliManager.ts +++ b/main/src/services/panels/cli/AbstractCliManager.ts @@ -5,13 +5,12 @@ import * as os from 'os'; import { execSync, exec } from 'child_process'; import { promisify } from 'util'; import type { Logger } from '../../../utils/logger'; +import { getPtyHostRuntime, type PtyHandleLike } from '../../../core/runtime'; import type { ConfigManager } from '../../configManager'; import type { ConversationMessage } from '../../../database/models'; import { getShellPath, findExecutableInPath } from '../../../utils/shellPath'; import { findNodeExecutable } from '../../../utils/nodeFinder'; import { GIT_ATTRIBUTION_ENV } from '../../../utils/attribution'; -import { getPtyHostSupervisor } from '../../../index'; -import type { PtyHandle } from '../../../ptyHost/ptyHostSupervisor'; const LAST_OUTPUT_TAIL_BYTES = 16 * 1024; @@ -700,7 +699,7 @@ export abstract class AbstractCliManager extends EventEmitter { await new Promise(resolve => setTimeout(resolve, 500)); } - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); const useHost = (this.configManager?.getUsePtyHost() ?? false) && supervisor !== null; if (spawnAttempt === 0 && !(global as typeof global & Record)[needsNodeFallbackKey]) { @@ -818,7 +817,7 @@ export abstract class AbstractCliManager extends EventEmitter { cols: number, rows: number ): Promise { - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); if (!supervisor) { // Guard: caller must have checked already; if we got here without one, // surface a classifier-agnostic OTHER to avoid accidental fallback. @@ -855,7 +854,7 @@ export abstract class AbstractCliManager extends EventEmitter { * `(exitCode, signal)` shape to the `{exitCode, signal}` object shape the * rest of this file (and `pty.IPty`) expects. */ - private wrapPtyHandle(handle: PtyHandle, pid: number): PtyLike { + private wrapPtyHandle(handle: PtyHandleLike, pid: number): PtyLike { return { pid, write(data: string | Buffer): Promise { diff --git a/main/src/services/panels/logPanel/logsManager.ts b/main/src/services/panels/logPanel/logsManager.ts index 21b11e92..34b52551 100644 --- a/main/src/services/panels/logPanel/logsManager.ts +++ b/main/src/services/panels/logPanel/logsManager.ts @@ -1,9 +1,9 @@ import { ChildProcess, spawn, exec, execSync } from 'child_process'; import * as os from 'os'; import { ToolPanel, LogsPanelState } from '../../../../../shared/types/panels'; +import { getPaneEventSink } from '../../../core/runtime'; import { panelManager } from '../../panelManager'; import { addSessionLog, cleanupSessionLogs } from '../../../ipc/logs'; -import { mainWindow } from '../../../index'; import { getShellPath } from '../../../utils/shellPath'; import type { AnalyticsManager } from '../../analyticsManager'; import { WSLContext } from '../../../utils/wslUtils'; @@ -28,6 +28,10 @@ export class LogsManager { this.analyticsManager = analyticsManager; } + private sendRendererEvent(channel: string, ...args: unknown[]): void { + getPaneEventSink().send(channel, ...args); + } + /** * Get or create singleton logs panel for session */ @@ -42,9 +46,7 @@ export class LogsManager { // Emit panel:created event to ensure frontend adds it back if it was closed // This is necessary because closing a panel in the frontend removes it from the store // but doesn't delete it from the backend database - if (mainWindow) { - mainWindow.webContents.send('panel:created', existingLogs); - } + this.sendRendererEvent('panel:created', existingLogs); return existingLogs; } @@ -128,18 +130,16 @@ export class LogsManager { await panelManager.setActivePanel(sessionId, panel.id); // Emit process started event - if (mainWindow) { - mainWindow.webContents.send('panel:event', { - type: 'process:started', - source: { - panelId: panel.id, - panelType: 'logs', - sessionId - }, - data: { command, cwd }, - timestamp: startTime - }); - } + this.sendRendererEvent('panel:event', { + type: 'process:started', + source: { + panelId: panel.id, + panelType: 'logs', + sessionId + }, + data: { command, cwd }, + timestamp: startTime + }); // Get enhanced shell PATH for packaged apps const shellPath = getShellPath(); @@ -380,25 +380,23 @@ export class LogsManager { addSessionLog(sessionId, level, content, 'Script'); // Emit output event - if (mainWindow) { - mainWindow.webContents.send('panel:event', { - type: 'process:output', - source: { - panelId, - panelType: 'logs', - sessionId - }, - data: { content, type }, - timestamp: new Date().toISOString() - }); - - // Also send logs-specific output event for the panel - mainWindow.webContents.send('logs:output', { + this.sendRendererEvent('panel:event', { + type: 'process:output', + source: { panelId, - content, - type - }); - } + panelType: 'logs', + sessionId + }, + data: { content, type }, + timestamp: new Date().toISOString() + }); + + // Also send logs-specific output event for the panel + this.sendRendererEvent('logs:output', { + panelId, + content, + type + }); // Update panel state const panel = await panelManager.getPanel(panelId); @@ -478,24 +476,22 @@ export class LogsManager { } // Emit process ended event - if (mainWindow) { - mainWindow.webContents.send('panel:event', { - type: 'process:ended', - source: { - panelId, - panelType: 'logs', - sessionId - }, - data: { exitCode: code }, - timestamp: new Date().toISOString() - }); - - // Also send specific event for the panel - mainWindow.webContents.send('process:ended', { + this.sendRendererEvent('panel:event', { + type: 'process:ended', + source: { panelId, - exitCode: code - }); - } + panelType: 'logs', + sessionId + }, + data: { exitCode: code }, + timestamp: new Date().toISOString() + }); + + // Also send specific event for the panel + this.sendRendererEvent('process:ended', { + panelId, + exitCode: code + }); // Add final log entry const message = code === 0 @@ -541,4 +537,4 @@ export class LogsManager { } } -export const logsManager = LogsManager.getInstance(); \ No newline at end of file +export const logsManager = LogsManager.getInstance(); diff --git a/main/src/services/runCommandManager.ts b/main/src/services/runCommandManager.ts index eab9c0d4..c9fe49da 100644 --- a/main/src/services/runCommandManager.ts +++ b/main/src/services/runCommandManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import * as pty from '@lydell/node-pty'; +import { getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; import type { Logger } from '../utils/logger'; import type { DatabaseService } from '../database/database'; import type { ProjectRunCommand } from '../database/models'; @@ -8,9 +9,7 @@ import { ShellDetector } from '../utils/shellDetector'; import * as os from 'os'; import { exec } from 'child_process'; import { promisify } from 'util'; -import { configManager, getPtyHostSupervisor } from '../index'; import { GIT_ATTRIBUTION_ENV } from '../utils/attribution'; -import type { PtyHandle, PtyHostSupervisor } from '../ptyHost/ptyHostSupervisor'; /** * IPty-compatible shim over a ptyHost `PtyHandle`. @@ -31,9 +30,9 @@ class RunCommandPtyShim implements pty.IPty { readonly process = 'ptyHost'; handleFlowControl = false; readonly ptyId: string; - private readonly handle: PtyHandle; + private readonly handle: PtyHandleLike; - constructor(handle: PtyHandle, cols: number, rows: number) { + constructor(handle: PtyHandleLike, cols: number, rows: number) { this.handle = handle; this.ptyId = handle.id; this.pid = handle.pid; @@ -169,7 +168,7 @@ export class RunCommandManager extends EventEmitter { } // Get the user's default shell - const preferredShell = configManager.getPreferredShell(); + const preferredShell = getRuntimeConfigManager().getPreferredShell(); const shellInfo = ShellDetector.getDefaultShell(preferredShell); this.logger?.verbose(`Using shell: ${shellInfo.path} (${shellInfo.name})`); @@ -200,10 +199,11 @@ export class RunCommandManager extends EventEmitter { // we fall back to the legacy in-main `pty.spawn`. Behavior is // byte-identical under setting-off or when the supervisor is // unavailable. - const useFlag = configManager.getUsePtyHost(); - let supervisor: PtyHostSupervisor | null = null; + const runtimeConfigManager = getRuntimeConfigManager(); + const useFlag = runtimeConfigManager.getUsePtyHost(); + let supervisor: PtyHostRuntime | null = null; if (useFlag) { - supervisor = getPtyHostSupervisor(); + supervisor = getPtyHostRuntime(); if (!supervisor) { this.logger?.warn('[ptyHost] supervisor unavailable, falling back to legacy pty.spawn for run-command'); } diff --git a/main/src/services/scriptExecutionTracker.ts b/main/src/services/scriptExecutionTracker.ts index d48eac27..5bfe02c3 100644 --- a/main/src/services/scriptExecutionTracker.ts +++ b/main/src/services/scriptExecutionTracker.ts @@ -7,7 +7,7 @@ */ import { EventEmitter } from 'events'; -import { mainWindow } from '../index'; +import { getPaneEventSink } from '../core/runtime'; export type ScriptType = 'session' | 'project'; @@ -110,12 +110,10 @@ export class ScriptExecutionTracker extends EventEmitter { this.closingScript = this.runningScript; // Emit closing event to notify frontend - if (mainWindow) { - if (type === 'session') { - mainWindow.webContents.send('script-closing', id); - } else { - mainWindow.webContents.send('project-script-closing', { projectId: id }); - } + if (type === 'session') { + getPaneEventSink().send('script-closing', id); + } else { + getPaneEventSink().send('project-script-closing', { projectId: id }); } this.emit('script-closing', { type, id }); @@ -142,12 +140,10 @@ export class ScriptExecutionTracker extends EventEmitter { * Emit state change events to frontend based on type */ private emitStateChange(type: ScriptType, id: string | number | null): void { - if (!mainWindow) return; - if (type === 'session') { - mainWindow.webContents.send('script-session-changed', id); + getPaneEventSink().send('script-session-changed', id); } else { - mainWindow.webContents.send('project-script-changed', { projectId: id }); + getPaneEventSink().send('project-script-changed', { projectId: id }); } } diff --git a/main/src/services/sessionManager.ts b/main/src/services/sessionManager.ts index 60ab083e..10491bdc 100644 --- a/main/src/services/sessionManager.ts +++ b/main/src/services/sessionManager.ts @@ -7,8 +7,8 @@ import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; import { spawn, ChildProcess, exec, execSync } from 'child_process'; +import { getRuntimeConfigManager } from '../core/runtime'; import { ShellDetector } from '../utils/shellDetector'; -import { configManager } from '../index'; import type { Session, SessionUpdate, SessionOutput } from '../types/session'; import type { DatabaseService } from '../database/database'; import type { Session as DbSession, CreateSessionData, UpdateSessionData, ConversationMessage, PromptMarker, ExecutionDiff, CreateExecutionDiffData, Project } from '../database/models'; @@ -1123,7 +1123,7 @@ export class SessionManager extends EventEmitter { const shellPath = getShellPath(); // Get the user's default shell and command arguments - const preferredShell = configManager.getPreferredShell(); + const preferredShell = getRuntimeConfigManager().getPreferredShell(); const { shell, args } = ShellDetector.getShellCommandArgs(command, preferredShell); // Spawn the process with its own process group for easier termination diff --git a/main/src/services/taskQueue.ts b/main/src/services/taskQueue.ts index f8cacfcd..f59a10e8 100644 --- a/main/src/services/taskQueue.ts +++ b/main/src/services/taskQueue.ts @@ -1,4 +1,5 @@ import Bull from 'bull'; +import { getRuntimeConfigManager } from '../core/runtime'; import { SimpleQueue } from './simpleTaskQueue'; import { SessionManager } from './sessionManager'; import type { WorktreeManager } from './worktreeManager'; @@ -15,7 +16,6 @@ import type { DatabaseService } from '../database/database'; import type { Project } from '../database/models'; import { worktreeFileSyncService } from './worktreeFileSyncService'; import { terminalPanelManager } from './terminalPanelManager'; -import { configManager } from '../index'; import { detectProjectConfig } from './projectConfigDetector'; interface TaskQueueOptions { @@ -269,7 +269,7 @@ export class TaskQueue { worktreePath, ctx.commandRunner, ctx.pathResolver.environment, - configManager.getWorktreeFileSyncEntries() + getRuntimeConfigManager().getWorktreeFileSyncEntries() ).then(async (installCommand) => { if (!installCommand) return; // Find the default terminal panel — may not exist yet if sync finished before diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 1d39d462..f969cdcd 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1,7 +1,7 @@ import * as pty from '@lydell/node-pty'; import { ToolPanel, TerminalPanelState, PanelEventType } from '../../../shared/types/panels'; +import { getPaneEventSink, getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; import { panelManager } from './panelManager'; -import { mainWindow, configManager, getPtyHostSupervisor } from '../index'; import * as os from 'os'; import * as path from 'path'; import { getShellPath } from '../utils/shellPath'; @@ -9,7 +9,6 @@ import { ShellDetector } from '../utils/shellDetector'; import type { AnalyticsManager } from './analyticsManager'; import { getWSLShellSpawn, buildWSLENV, WSLContext } from '../utils/wslUtils'; import { GIT_ATTRIBUTION_ENV } from '../utils/attribution'; -import type { PtyHandle, PtyHostSupervisor } from '../ptyHost/ptyHostSupervisor'; import { type FlowControlRecord, createFlowControlRecord, @@ -49,9 +48,9 @@ class PtyHandleShim implements pty.IPty { readonly process = 'ptyHost'; handleFlowControl = false; readonly ptyId: string; - private readonly handle: PtyHandle; + private readonly handle: PtyHandleLike; - constructor(handle: PtyHandle, cols: number, rows: number) { + constructor(handle: PtyHandleLike, cols: number, rows: number) { this.handle = handle; this.ptyId = handle.id; this.pid = handle.pid; @@ -277,6 +276,10 @@ export class TerminalPanelManager { this.analyticsManager = analyticsManager; } + private sendRendererEvent(channel: string, ...args: unknown[]): void { + getPaneEventSink().send(channel, ...args); + } + /** * Returns a map of sessionId → array of PTY PIDs for that session. * Used by resource monitoring to discover which processes belong to which session. @@ -377,15 +380,13 @@ export class TerminalPanelManager { // the per-window MessagePort so `electronAPI.ptyHost.onData` subscribers // fire. Both paths continue to run; the renderer short-circuits the // legacy handler once a `ptyId` is set to avoid double-delivery. - if (mainWindow) { - mainWindow.webContents.send('terminal:output', { - sessionId: terminal.sessionId, - panelId: terminal.panelId, - output: data - }); - } + this.sendRendererEvent('terminal:output', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + output: data + }); if (terminal.isPtyHost && terminal.ptyId) { - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); supervisor?.postDataToRenderers(terminal.ptyId, data); } @@ -412,7 +413,7 @@ export class TerminalPanelManager { */ private pausePty(terminal: TerminalProcess): Promise { if (terminal.isPtyHost && terminal.ptyId) { - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); if (supervisor) { return supervisor.pause(terminal.ptyId).catch((err: unknown) => { console.warn('[TerminalPanelManager] ptyHost pause failed', err); @@ -428,7 +429,7 @@ export class TerminalPanelManager { */ private resumePty(terminal: TerminalProcess): void { if (terminal.isPtyHost && terminal.ptyId) { - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); if (supervisor) { supervisor.resume(terminal.ptyId).catch((err: unknown) => { console.warn('[TerminalPanelManager] ptyHost resume failed', err); @@ -524,7 +525,7 @@ export class TerminalPanelManager { shellArgs = wslShell.args; spawnCwd = undefined; // WSL handles cwd } else { - const preferredShell = configManager.getPreferredShell(); + const preferredShell = getRuntimeConfigManager().getPreferredShell(); const shellInfo = ShellDetector.getDefaultShell(preferredShell); shellPath = shellInfo.path; shellArgs = shellInfo.args || []; @@ -598,13 +599,14 @@ export class TerminalPanelManager { }; // Read the setting once per spawn so we don't scatter config reads. - // `getPtyHostSupervisor()` returns null when the setting is off or when + // `getPtyHostRuntime()` returns null when the setting is off or when // supervisor startup failed; in either case we transparently fall back to // the legacy `pty.spawn` path. - const useFlag = configManager.getUsePtyHost(); - let supervisor: PtyHostSupervisor | null = null; + const runtimeConfigManager = getRuntimeConfigManager(); + const useFlag = runtimeConfigManager.getUsePtyHost(); + let supervisor: PtyHostRuntime | null = null; if (useFlag) { - supervisor = getPtyHostSupervisor(); + supervisor = getPtyHostRuntime(); if (!supervisor) { console.warn('[ptyHost] supervisor unavailable, falling back to legacy pty.spawn'); } @@ -680,8 +682,8 @@ export class TerminalPanelManager { // `TerminalPanel.tsx` can use `electronAPI.ptyHost.onData(ptyId, ...)` // under the flag. Flag-off path skips this: the renderer keeps using // the legacy `terminal:output` channel. - if (usePtyHost && ptyHostId && mainWindow) { - mainWindow.webContents.send('terminal:ptyReady', { + if (usePtyHost && ptyHostId) { + this.sendRendererEvent('terminal:ptyReady', { sessionId: panel.sessionId, panelId: panel.id, ptyId: ptyHostId, @@ -746,9 +748,7 @@ export class TerminalPanelManager { } // Emit to renderer - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('terminal:cliReady', { panelId }); - } + this.sendRendererEvent('terminal:cliReady', { panelId }); }; // Listen for first CLI output after command injection. @@ -875,12 +875,10 @@ export class TerminalPanelManager { const newState = lastEnter > lastLeave; if (newState !== terminal.isAlternateScreen) { terminal.isAlternateScreen = newState; - if (mainWindow) { - mainWindow.webContents.send('terminal:alternateScreen', { - panelId: terminal.panelId, - active: newState - }); - } + this.sendRendererEvent('terminal:alternateScreen', { + panelId: terminal.panelId, + active: newState + }); } } @@ -975,14 +973,12 @@ export class TerminalPanelManager { this.terminals.delete(terminal.panelId); // Notify frontend (include signal for crash detection) - if (mainWindow) { - mainWindow.webContents.send('terminal:exited', { - sessionId: terminal.sessionId, - panelId: terminal.panelId, - exitCode: exitCode.exitCode, - signal: exitCode.signal ?? null - }); - } + this.sendRendererEvent('terminal:exited', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + exitCode: exitCode.exitCode, + signal: exitCode.signal ?? null + }); }); } @@ -1160,17 +1156,17 @@ export class TerminalPanelManager { // Send scrollback to frontend. Dual-path mirrors `flushOutputBuffer`: // `terminal:output` IPC for legacy subscribers, ptyHost port for flag-on. - if (mainWindow && state.scrollbackBuffer) { + if (state.scrollbackBuffer) { const output = typeof state.scrollbackBuffer === 'string' ? state.scrollbackBuffer + restorationMsg : state.scrollbackBuffer.join('\n') + restorationMsg; - mainWindow.webContents.send('terminal:output', { + this.sendRendererEvent('terminal:output', { sessionId: panel.sessionId, panelId: panel.id, output, }); if (terminal.isPtyHost && terminal.ptyId) { - const supervisor = getPtyHostSupervisor(); + const supervisor = getPtyHostRuntime(); supervisor?.postDataToRenderers(terminal.ptyId, output); } } @@ -1215,14 +1211,12 @@ export class TerminalPanelManager { } private emitActivityStatus(terminal: TerminalProcess): void { - if (mainWindow) { - mainWindow.webContents.send('panel:activityStatus', { - panelId: terminal.panelId, - sessionId: terminal.sessionId, - status: terminal.activityStatus, - lastActivityAt: terminal.lastActivity.toISOString() - }); - } + this.sendRendererEvent('panel:activityStatus', { + panelId: terminal.panelId, + sessionId: terminal.sessionId, + status: terminal.activityStatus, + lastActivityAt: terminal.lastActivity.toISOString() + }); } destroyTerminal(panelId: string): void { diff --git a/main/src/services/terminalSessionManager.ts b/main/src/services/terminalSessionManager.ts index 19a2fe35..227eeba0 100644 --- a/main/src/services/terminalSessionManager.ts +++ b/main/src/services/terminalSessionManager.ts @@ -1,13 +1,12 @@ import { EventEmitter } from 'events'; import * as pty from '@lydell/node-pty'; +import { getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; import { getShellPath } from '../utils/shellPath'; import { ShellDetector } from '../utils/shellDetector'; import * as os from 'os'; import { exec } from 'child_process'; import { promisify } from 'util'; import { GIT_ATTRIBUTION_ENV } from '../utils/attribution'; -import { configManager, getPtyHostSupervisor } from '../index'; -import type { PtyHandle, PtyHostSupervisor } from '../ptyHost/ptyHostSupervisor'; /** * IPty-compatible shim over a ptyHost `PtyHandle`. @@ -27,9 +26,9 @@ class TerminalSessionPtyShim implements pty.IPty { readonly process = 'ptyHost'; handleFlowControl = false; readonly ptyId: string; - private readonly handle: PtyHandle; + private readonly handle: PtyHandleLike; - constructor(handle: PtyHandle, cols: number, rows: number) { + constructor(handle: PtyHandleLike, cols: number, rows: number) { this.handle = handle; this.ptyId = handle.id; this.pid = handle.pid; @@ -142,10 +141,11 @@ export class TerminalSessionManager extends EventEmitter { // is routed through the ptyHost `UtilityProcess`; otherwise fall back to // the legacy in-main `pty.spawn`. Under setting-off or when the // supervisor is unavailable, behavior is byte-identical. - const useFlag = configManager.getUsePtyHost(); - let supervisor: PtyHostSupervisor | null = null; + const runtimeConfigManager = getRuntimeConfigManager(); + const useFlag = runtimeConfigManager.getUsePtyHost(); + let supervisor: PtyHostRuntime | null = null; if (useFlag) { - supervisor = getPtyHostSupervisor(); + supervisor = getPtyHostRuntime(); if (!supervisor) { console.warn('[ptyHost] supervisor unavailable, falling back to legacy pty.spawn for terminal-session'); } From 0c3055778cbc07855705ddf4c62feef277279fb7 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 11:39:41 -0700 Subject: [PATCH 2/2] Add daemon boundary import regression test --- main/src/core/importBoundary.test.ts | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 main/src/core/importBoundary.test.ts diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts new file mode 100644 index 00000000..942b8701 --- /dev/null +++ b/main/src/core/importBoundary.test.ts @@ -0,0 +1,47 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; + +const MAIN_SRC_ROOT = path.resolve(process.cwd(), 'src'); + +function readMainSrcFile(relativePath: string): string { + return fs.readFileSync(path.join(MAIN_SRC_ROOT, relativePath), 'utf8'); +} + +describe('daemon/client import boundary', () => { + it('keeps targeted services off bootstrap globals', () => { + const serviceFiles = [ + 'events.ts', + 'services/panelManager.ts', + 'services/terminalPanelManager.ts', + 'services/terminalSessionManager.ts', + 'services/runCommandManager.ts', + 'services/sessionManager.ts', + 'services/scriptExecutionTracker.ts', + 'services/taskQueue.ts', + 'services/panels/cli/AbstractCliManager.ts', + 'services/panels/logPanel/logsManager.ts', + ]; + + for (const relativePath of serviceFiles) { + 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', + 'services/panelManager.ts', + 'services/terminalPanelManager.ts', + 'services/panels/logPanel/logsManager.ts', + ]; + + for (const relativePath of eventFiles) { + const source = readMainSrcFile(relativePath); + expect(source, relativePath).not.toContain('webContents.send('); + expect(source, relativePath).not.toContain('mainWindow'); + } + }); +});