From 23ac5c12b489780466d7f6360b8526c97d3b7ebb Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 11:20:11 -0700 Subject: [PATCH 001/111] Enforce maintained regression checks --- .github/workflows/quality.yml | 42 +++- frontend/src/App.tsx | 2 + frontend/src/hooks/useNotifications.ts | 10 +- tests/electronApiMock.ts | 122 +++++++++++ tests/git-status.spec.ts | 121 ---------- tests/health-check.spec.ts | 9 +- tests/permissions-ui-fixed.spec.ts | 85 ------- tests/permissions-ui.spec.ts | 86 -------- tests/permissions.spec.ts | 292 ------------------------- tests/setup.ts | 29 --- tests/smoke.spec.ts | 29 ++- 11 files changed, 196 insertions(+), 631 deletions(-) create mode 100644 tests/electronApiMock.ts delete mode 100644 tests/git-status.spec.ts delete mode 100644 tests/permissions-ui-fixed.spec.ts delete mode 100644 tests/permissions-ui.spec.ts delete mode 100644 tests/permissions.spec.ts delete mode 100644 tests/setup.ts diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a37d8129..9baacbe1 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,7 +9,7 @@ on: jobs: quality-checks: - name: Quality Checks + name: Quality Checks + Smoke runs-on: ubuntu-latest steps: @@ -32,4 +32,42 @@ jobs: run: pnpm typecheck - name: Run linting - run: pnpm lint \ No newline at end of file + run: pnpm lint + + - name: Install Playwright browser dependencies + run: pnpm exec playwright install --with-deps chromium + + - name: Run minimal functional smoke tests + run: xvfb-run -a pnpm test:ci:minimal + env: + PANE_DIR: ${{ runner.temp }}/pane-ci + + cross-os-main-tests: + name: Main Process Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.15.1' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run main process type checking + run: pnpm --filter main typecheck + + - name: Run main process unit tests + run: pnpm --filter main test diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29c2db07..1ac5eeeb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -204,6 +204,8 @@ function App() { // Detect unclean shutdown from previous session and notify user useEffect(() => { + if (!window.electronAPI?.events?.onUncleanShutdownDetected) return; + return window.electronAPI.events.onUncleanShutdownDetected(() => { showNotification( 'Pane didn\'t shut down cleanly', diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 84307402..6ba2f243 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -42,18 +42,24 @@ export function useNotifications() { const windowFocusedRef = useRef(typeof document !== 'undefined' ? document.hasFocus() : true); useEffect(() => { + const electronWindow = window.electronAPI?.window; + const electronEvents = window.electronAPI?.events; + if (!electronWindow?.isFocused || !electronEvents?.onWindowFocusChanged) { + return; + } + // Pull authoritative initial state from the main process. document.hasFocus() // is a cold-start fallback; if DevTools or another Electron sub-window owns // DOM focus at mount time, document.hasFocus() returns false even though // BrowserWindow.isFocused() is true. Without this pull, no focus event // fires until the next focus change, and notifications misfire in between. - window.electronAPI.window.isFocused().then((focused) => { + electronWindow.isFocused().then((focused) => { windowFocusedRef.current = focused; }).catch(() => { // Leave the document.hasFocus() bootstrap in place on IPC failure. }); - const unsubscribe = window.electronAPI.events.onWindowFocusChanged((focused) => { + const unsubscribe = electronEvents.onWindowFocusChanged((focused) => { windowFocusedRef.current = focused; }); return unsubscribe; diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts new file mode 100644 index 00000000..c3895fd0 --- /dev/null +++ b/tests/electronApiMock.ts @@ -0,0 +1,122 @@ +import type { Page } from '@playwright/test'; + +export async function installElectronApiMock(page: Page) { + await page.addInitScript(() => { + const success = (data: unknown = null) => Promise.resolve({ success: true, data }); + const unsubscribe = () => undefined; + const subscribe = () => unsubscribe; + + const namespace = (overrides: Record = {}) => + new Proxy(overrides, { + get(target, prop: string | symbol) { + if (prop in target) { + return target[prop as keyof typeof target]; + } + return () => success(); + }, + }); + + const events = new Proxy({}, { + get: () => subscribe, + }); + + const invoke = (channel: string) => { + if (channel === 'preferences:get') { + return success('true'); + } + if (channel === 'archive:get-progress') { + return success(null); + } + return success(); + }; + + const electronAPI = { + invoke, + events, + window: { + isFocused: () => Promise.resolve(true), + }, + getPlatform: () => Promise.resolve('linux'), + getVersionInfo: () => success({ + version: 'test', + current: 'test', + latest: 'test', + hasUpdate: false, + }), + isPackaged: () => Promise.resolve(false), + checkForUpdates: () => success({ hasUpdate: false }), + openExternal: () => undefined, + analytics: namespace({ + getIdentity: () => success({ distinctId: 'test', hasConsent: false }), + onMainEvent: subscribe, + syncDistinctId: () => undefined, + }), + cloud: namespace({ + getState: () => success({ status: 'idle' }), + onStateChanged: subscribe, + startPolling: () => success(), + stopPolling: () => success(), + }), + config: namespace({ + get: () => success({}), + getAvailableShells: () => success([]), + getMonospaceFonts: () => success([]), + getSessionPreferences: () => success({}), + }), + folders: namespace({ + getByProject: () => success([]), + }), + onboarding: namespace({ + detectEnvironment: () => success({}), + setupDefaultRepo: () => success({}), + starRepo: () => success({}), + }), + panels: namespace({ + getSessionPanels: () => success([]), + shouldAutoCreate: () => success(false), + }), + projects: namespace({ + getAll: () => success([]), + getActive: () => success(null), + refreshGitStatus: () => success(), + }), + prompts: namespace({ + getAll: () => success([]), + }), + ptyHost: namespace({ + ack: () => Promise.resolve(), + onData: subscribe, + onExit: subscribe, + }), + resourceMonitor: namespace({ + getSnapshot: () => success(null), + startActive: () => success(), + stopActive: () => success(), + }), + sessions: namespace({ + getAll: () => success([]), + getAllWithProjects: () => success([]), + getArchivedWithProjects: () => success([]), + getResumable: () => success([]), + }), + uiState: namespace({ + getExpanded: () => success([]), + saveSessionSortAscending: () => success(), + }), + }; + + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: electronAPI, + }); + + Object.defineProperty(window, 'electron', { + configurable: true, + value: { + invoke, + on: subscribe, + off: () => undefined, + }, + }); + }); +} diff --git a/tests/git-status.spec.ts b/tests/git-status.spec.ts deleted file mode 100644 index 1f90ed5d..00000000 --- a/tests/git-status.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { test, expect, Page } from '@playwright/test'; - -async function setupTest(page: Page): Promise { - // Navigate to the app - await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); - - // Close welcome dialog if present - const getStartedButton = page.locator('button:has-text("Get Started")'); - if (await getStartedButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await getStartedButton.click(); - } - - // Wait for the UI to load - await page.waitForSelector('[data-testid="sidebar"], .sidebar, aside', { timeout: 10000 }); -} - -test.describe('Git Status Indicators - Smoke Test', () => { - test('should display git status indicator for sessions', async ({ page }) => { - await setupTest(page); - - // Check if there are any existing sessions with git status indicators - const existingStatusIndicators = page.locator('[data-testid$="-git-status"]'); - const existingCount = await existingStatusIndicators.count(); - - if (existingCount > 0) { - // Test existing sessions - const firstIndicator = existingStatusIndicators.first(); - await expect(firstIndicator).toBeVisible(); - - // Verify it has a git state attribute - const gitState = await firstIndicator.getAttribute('data-git-state'); - expect(gitState).toBeTruthy(); - expect(['clean', 'modified', 'ahead', 'behind', 'diverged', 'conflict', 'untracked', 'unknown']).toContain(gitState); - - // Check that it can show loading state when clicked - const sessionItem = page.locator('[data-testid^="session-"]').first(); - await sessionItem.click(); - - // Wait for either loading state to appear or git state to update - // This ensures we catch the transition properly without arbitrary delays - const indicatorTestId = await firstIndicator.getAttribute('data-testid'); - await page.waitForFunction( - (testId) => { - const indicator = document.querySelector(`[data-testid="${testId}"]`); - if (!indicator) return false; - - // Check if loading state is active or if git state has been updated - const isLoading = indicator.getAttribute('data-git-loading') === 'true'; - const hasGitState = indicator.hasAttribute('data-git-state'); - - // We're ready when either loading is shown or git state is present - return isLoading || hasGitState; - }, - indicatorTestId, - { timeout: 2000 } - ).catch(() => { - // If timeout, that's okay - the indicator might already have the state - }); - - // The indicator should still be visible (either in loading state or with status) - await expect(firstIndicator).toBeVisible(); - } else { - // No existing sessions found - verify the UI is still functional - // by checking that the sidebar is present (already checked in setupTest) - // This branch passing without errors indicates the test succeeded - } - - // Verify the UI is fully loaded by checking for project elements or sidebar - // The create session button appears on hover, so we'll check for the sidebar instead - const sidebar = page.locator('[data-testid="sidebar"], .sidebar, aside'); - await expect(sidebar).toBeVisible(); - }); - - test('should handle loading states gracefully', async ({ page }) => { - await setupTest(page); - - // If there are sessions, click on one to potentially trigger loading state - const sessionItems = page.locator('[data-testid^="session-"]'); - const sessionCount = await sessionItems.count(); - - if (sessionCount > 0) { - await sessionItems.first().click(); - - // Check if any git status indicators show loading state - const loadingIndicators = page.locator('[data-git-loading="true"]'); - - // Loading state might appear briefly or not at all if cached - if (await loadingIndicators.count() > 0) { - // Verify loading indicator has proper structure - const loader = loadingIndicators.first().locator('.animate-spin'); - await expect(loader).toBeVisible(); - } - - // Wait for any loading to complete by checking for loading state to disappear - // and git status to be populated - await page.waitForFunction( - () => { - // Check if any loading indicators are still present - const loadingElements = document.querySelectorAll('[data-git-loading="true"]'); - const hasLoadingState = loadingElements.length > 0; - - // Check if git status indicators have a valid state - const statusElements = document.querySelectorAll('[data-testid$="-git-status"][data-git-state]'); - const hasGitState = statusElements.length > 0; - - // Loading is complete when there are no loading states and we have git states - return !hasLoadingState && hasGitState; - }, - { timeout: 5000 } - ).catch(() => { - // If the wait times out, it might mean no git status is available, which is okay - }); - - // Verify indicators are still visible after loading - const statusIndicators = page.locator('[data-testid$="-git-status"]'); - if (await statusIndicators.count() > 0) { - await expect(statusIndicators.first()).toBeVisible(); - } - } - }); -}); \ No newline at end of file diff --git a/tests/health-check.spec.ts b/tests/health-check.spec.ts index 8a9da1a6..bc1c708b 100644 --- a/tests/health-check.spec.ts +++ b/tests/health-check.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; + +test.beforeEach(async ({ page }) => { + await installElectronApiMock(page); +}); test.describe('Health Check', () => { test('Electron app should start', async ({ page }) => { @@ -11,9 +16,9 @@ test.describe('Health Check', () => { // Check that the page has loaded const title = await page.title(); expect(title).toBeTruthy(); - + await expect(page.getByText('Something went wrong')).toHaveCount(0); // Take a screenshot for debugging await page.screenshot({ path: 'test-results/health-check.png' }); }); -}); \ No newline at end of file +}); diff --git a/tests/permissions-ui-fixed.spec.ts b/tests/permissions-ui-fixed.spec.ts deleted file mode 100644 index 390bdb6b..00000000 --- a/tests/permissions-ui-fixed.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Permission UI Elements', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - // Close welcome dialog if present - const getStartedButton = page.locator('button:has-text("Get Started")'); - if (await getStartedButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await getStartedButton.click(); - } - }); - - test('Settings should have permission mode option', async ({ page }) => { - // Click settings button with retry - const settingsButton = page.locator('[data-testid="settings-button"]'); - await expect(settingsButton).toBeVisible({ timeout: 10000 }); - await settingsButton.click(); - - // Wait for settings dialog with better selector - const settingsDialog = page.locator('div[role="dialog"]:has-text("Settings")'); - await expect(settingsDialog).toBeVisible({ timeout: 10000 }); - - // Check for permission mode section - await expect(page.locator('text="Default Permission Mode"')).toBeVisible({ timeout: 5000 }); - await expect(page.locator('text="Skip Permissions (Default)"')).toBeVisible(); - await expect(page.locator('text="Approve Actions"')).toBeVisible(); - - // Check radio buttons - await expect(page.locator('input[name="defaultPermissionMode"][value="ignore"]')).toBeVisible(); - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeVisible(); - - // Default should be 'ignore' - await expect(page.locator('input[name="defaultPermissionMode"][value="ignore"]')).toBeChecked(); - }); - - test('Can change default permission mode', async ({ page }) => { - // Click settings button - const settingsButton = page.locator('[data-testid="settings-button"]'); - await expect(settingsButton).toBeVisible({ timeout: 10000 }); - await settingsButton.click(); - - // Wait for settings dialog - const settingsDialog = page.locator('div[role="dialog"]:has-text("Settings")'); - await expect(settingsDialog).toBeVisible({ timeout: 10000 }); - - // Click approve mode - const approveRadio = page.locator('input[name="defaultPermissionMode"][value="approve"]'); - await approveRadio.click(); - - // Save settings - click the Save button - const saveButton = page.locator('button[type="submit"]:has-text("Save")'); - await expect(saveButton).toBeVisible(); - await saveButton.click(); - - // Wait for settings to close - check dialog is gone - await expect(settingsDialog).toBeHidden({ timeout: 10000 }); - - // Give a moment for settings to persist - await page.waitForTimeout(500); - - // Re-open settings - await settingsButton.click(); - - // Wait for dialog again - await expect(settingsDialog).toBeVisible({ timeout: 10000 }); - - // Check that approve mode is now selected - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeChecked(); - }); - - test('Permission dialog component renders correctly', async ({ page }) => { - // This test checks if the permission dialog component exists in the codebase - // For a real test, we'd need to trigger a permission request - - // Navigate to a page that might show permissions - await page.goto('/'); - - // For now, just check that the app loaded - await expect(page.locator('body')).toBeVisible(); - - // Could add more specific tests here when we know how to trigger permission dialogs - }); -}); \ No newline at end of file diff --git a/tests/permissions-ui.spec.ts b/tests/permissions-ui.spec.ts deleted file mode 100644 index 6ae4cce1..00000000 --- a/tests/permissions-ui.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Permission UI Elements', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForLoadState('networkidle'); - - // Close welcome dialog if present - const getStartedButton = page.locator('button:has-text("Get Started")'); - if (await getStartedButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await getStartedButton.click(); - } - }); - - test('Settings should have permission mode option', async ({ page }) => { - // Click settings button - await page.click('[data-testid="settings-button"]'); - - // Wait for settings dialog - await page.waitForSelector('text="Settings"'); - - // Check for permission mode section - await expect(page.locator('text="Default Permission Mode"')).toBeVisible(); - await expect(page.locator('text="Skip Permissions (Default)"')).toBeVisible(); - await expect(page.locator('text="Approve Actions"')).toBeVisible(); - - // Check radio buttons - await expect(page.locator('input[name="defaultPermissionMode"][value="ignore"]')).toBeVisible(); - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeVisible(); - - // Default should be 'ignore' - await expect(page.locator('input[name="defaultPermissionMode"][value="ignore"]')).toBeChecked(); - }); - - test('Can change default permission mode', async ({ page }) => { - // Click settings button - await page.click('[data-testid="settings-button"]'); - - // Wait for settings dialog - await page.waitForSelector('text="Settings"'); - - // Click approve mode - await page.click('input[name="defaultPermissionMode"][value="approve"]'); - - // Save settings - await page.click('button:has-text("Save")'); - - // Wait for settings to close - await page.waitForSelector('text="Settings"', { state: 'hidden' }); - - // Re-open settings - await page.click('[data-testid="settings-button"]'); - - // Verify it was saved - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeChecked(); - }); - - test('Permission dialog component renders correctly', async ({ page }) => { - // Inject a mock permission dialog by evaluating in page context - await page.evaluate(() => { - // Create a temporary container for testing - const container = document.createElement('div'); - container.innerHTML = ` -
-
-
-

Permission Required

-

Claude wants to Execute shell commands

-
-
- - -
-
-
- `; - document.body.appendChild(container); - }); - - // Check that permission dialog elements are visible - await expect(page.locator('text="Permission Required"')).toBeVisible(); - await expect(page.locator('text="Claude wants to Execute shell commands"')).toBeVisible(); - await expect(page.locator('button:has-text("Allow")')).toBeVisible(); - await expect(page.locator('button:has-text("Deny")')).toBeVisible(); - }); -}); \ No newline at end of file diff --git a/tests/permissions.spec.ts b/tests/permissions.spec.ts deleted file mode 100644 index 301cfefa..00000000 --- a/tests/permissions.spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { setupTestProject, cleanupTestProject } from './setup'; - -test.describe('Permission Flow', () => { - let testProjectPath: string; - - test.beforeAll(async () => { - testProjectPath = await setupTestProject(); - }); - - test.afterAll(async () => { - await cleanupTestProject(testProjectPath); - }); - // Helper to navigate to the app and set up a project - async function navigateToApp(page) { - await page.goto('/'); - // Wait for the app to load - await page.waitForLoadState('networkidle'); - - // Handle Welcome dialog if it appears - const getStartedButton = page.locator('button:has-text("Get Started")'); - if (await getStartedButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await getStartedButton.click(); - // Wait for welcome dialog to close - await page.waitForSelector('text="Welcome to Pane"', { state: 'hidden' }); - } - - // Check if we need to select a project - const selectProjectButton = page.locator('button:has-text("Select Project")'); - if (await selectProjectButton.isVisible({ timeout: 2000 }).catch(() => false)) { - await selectProjectButton.click(); - - // Wait for project dialog - await page.waitForSelector('text="Select or Create Project"'); - - // Click create new project - await page.click('button:has-text("Create New Project")'); - - // Fill in project details - await page.fill('input[placeholder*="project name"]', 'Test Project'); - await page.fill('input[placeholder*="directory"]', testProjectPath); - - // Submit - await page.click('button[type="submit"]:has-text("Create")'); - - // Wait for dialog to close - await page.waitForSelector('text="Select or Create Project"', { state: 'hidden' }); - } - - // Wait for the sidebar to be visible - await page.waitForSelector('[data-testid="sidebar"]', { timeout: 30000 }); - } - - // Helper to create a session with permission mode - async function createSessionWithPermissions(page, prompt: string, permissionMode: 'approve' | 'ignore') { - // Click create session button - await page.click('[data-testid="create-session-button"]'); - - // Wait for dialog - await page.waitForSelector('[data-testid="create-session-dialog"]'); - - // Fill in prompt - await page.fill('textarea[id="prompt"]', prompt); - - // Select permission mode - await page.click(`input[name="permissionMode"][value="${permissionMode}"]`); - - // Submit form - await page.click('button[type="submit"]'); - - // Wait for dialog to close - await page.waitForSelector('[data-testid="create-session-dialog"]', { state: 'hidden' }); - } - - test('should show permission mode option in create session dialog', async ({ page }) => { - await navigateToApp(page); - - // Open create session dialog - await page.click('[data-testid="create-session-button"]'); - - // Check that permission mode options are visible - await expect(page.locator('input[name="permissionMode"][value="ignore"]')).toBeVisible(); - await expect(page.locator('input[name="permissionMode"][value="approve"]')).toBeVisible(); - - // Check default selection - await expect(page.locator('input[name="permissionMode"][value="ignore"]')).toBeChecked(); - }); - - test('should show permission mode in settings', async ({ page }) => { - await navigateToApp(page); - - // Open settings - await page.click('[data-testid="settings-button"]'); - - // Check that default permission mode options are visible - await expect(page.locator('input[name="defaultPermissionMode"][value="ignore"]')).toBeVisible(); - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeVisible(); - }); - - test('should create session with skip permissions mode', async ({ page }) => { - await navigateToApp(page); - - await createSessionWithPermissions(page, 'Test skip permissions', 'ignore'); - - // Verify session was created - await expect(page.locator('text=Test skip permissions')).toBeVisible({ timeout: 10000 }); - }); - - test('should create session with approve permissions mode', async ({ page }) => { - await navigateToApp(page); - - await createSessionWithPermissions(page, 'Test approve permissions', 'approve'); - - // Verify session was created - await expect(page.locator('text=Test approve permissions')).toBeVisible({ timeout: 10000 }); - }); - - test('should show permission dialog when Claude requests permission', async ({ page }) => { - // This test would require mocking the Claude process to trigger a permission request - // For now, we'll test that the permission dialog component renders correctly - - await navigateToApp(page); - - // Inject a mock permission request - await page.evaluate(() => { - window.postMessage({ - type: 'permission:request', - data: { - id: 'test-request-1', - sessionId: 'test-session-1', - toolName: 'Bash', - input: { command: 'npm install', description: 'Install dependencies' }, - timestamp: Date.now() - } - }, '*'); - }); - - // Wait for permission dialog - await expect(page.locator('text=Permission Required')).toBeVisible({ timeout: 5000 }); - await expect(page.locator('text=Claude wants to Execute shell commands')).toBeVisible(); - await expect(page.locator('text=npm install')).toBeVisible(); - - // Check that Allow and Deny buttons are present - await expect(page.locator('button:has-text("Allow")')).toBeVisible(); - await expect(page.locator('button:has-text("Deny")')).toBeVisible(); - }); - - test('should handle allow permission response', async ({ page }) => { - await navigateToApp(page); - - // Inject a mock permission request - await page.evaluate(() => { - window.postMessage({ - type: 'permission:request', - data: { - id: 'test-request-2', - sessionId: 'test-session-2', - toolName: 'Write', - input: { file_path: '/tmp/test.txt', content: 'Hello World' }, - timestamp: Date.now() - } - }, '*'); - }); - - // Wait for permission dialog - await page.waitForSelector('text=Permission Required'); - - // Click Allow - await page.click('button:has-text("Allow")'); - - // Verify dialog is closed - await expect(page.locator('text=Permission Required')).not.toBeVisible(); - }); - - test('should handle deny permission response', async ({ page }) => { - await navigateToApp(page); - - // Inject a mock permission request - await page.evaluate(() => { - window.postMessage({ - type: 'permission:request', - data: { - id: 'test-request-3', - sessionId: 'test-session-3', - toolName: 'Delete', - input: { path: '/important/file.txt' }, - timestamp: Date.now() - } - }, '*'); - }); - - // Wait for permission dialog - await page.waitForSelector('text=Permission Required'); - - // Click Deny - await page.click('button:has-text("Deny")'); - - // Verify dialog is closed - await expect(page.locator('text=Permission Required')).not.toBeVisible(); - }); - - test('should show high risk warning for dangerous tools', async ({ page }) => { - await navigateToApp(page); - - // Inject a mock permission request for a dangerous tool - await page.evaluate(() => { - window.postMessage({ - type: 'permission:request', - data: { - id: 'test-request-4', - sessionId: 'test-session-4', - toolName: 'Bash', - input: { command: 'rm -rf /', description: 'Delete everything' }, - timestamp: Date.now() - } - }, '*'); - }); - - // Wait for permission dialog - await page.waitForSelector('text=Permission Required'); - - // Check for high risk warning - await expect(page.locator('text=High Risk Action')).toBeVisible(); - await expect(page.locator('text=This action could modify your system')).toBeVisible(); - }); - - test('should allow editing permission request input', async ({ page }) => { - await navigateToApp(page); - - // Inject a mock permission request - await page.evaluate(() => { - window.postMessage({ - type: 'permission:request', - data: { - id: 'test-request-5', - sessionId: 'test-session-5', - toolName: 'Write', - input: { file_path: '/tmp/test.txt', content: 'Original content' }, - timestamp: Date.now() - } - }, '*'); - }); - - // Wait for permission dialog - await page.waitForSelector('text=Permission Required'); - - // Click Edit button - await page.click('button:has-text("Edit")'); - - // Check that textarea is visible - const textarea = page.locator('textarea'); - await expect(textarea).toBeVisible(); - - // Verify original content is shown - const content = await textarea.inputValue(); - expect(content).toContain('Original content'); - - // Edit the content - await textarea.fill(JSON.stringify({ - file_path: '/tmp/test.txt', - content: 'Modified content' - }, null, 2)); - - // Click Allow - await page.click('button:has-text("Allow")'); - - // Verify dialog is closed - await expect(page.locator('text=Permission Required')).not.toBeVisible(); - }); - - test('should save default permission mode in settings', async ({ page }) => { - await navigateToApp(page); - - // Open settings - await page.click('[data-testid="settings-button"]'); - - // Select approve mode - await page.click('input[name="defaultPermissionMode"][value="approve"]'); - - // Save settings - await page.click('button:has-text("Save")'); - - // Wait for settings to close - await page.waitForSelector('text=Settings', { state: 'hidden' }); - - // Re-open settings to verify it was saved - await page.click('[data-testid="settings-button"]'); - - // Check that approve mode is selected - await expect(page.locator('input[name="defaultPermissionMode"][value="approve"]')).toBeChecked(); - }); -}); \ No newline at end of file diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index 9351fa84..00000000 --- a/tests/setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { chromium } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; - -export async function setupTestProject() { - // Create a temporary test project directory - const testProjectPath = path.join(os.tmpdir(), `pane-test-${Date.now()}`); - fs.mkdirSync(testProjectPath, { recursive: true }); - - // Initialize git in the test directory - const { execSync } = require('child_process'); - execSync('git init -b main', { cwd: testProjectPath, stdio: 'pipe' }); - execSync('git config user.email "test@example.com"', { cwd: testProjectPath, stdio: 'pipe' }); - execSync('git config user.name "Test User"', { cwd: testProjectPath, stdio: 'pipe' }); - execSync('touch README.md', { cwd: testProjectPath }); - execSync('git add .', { cwd: testProjectPath }); - execSync('git commit -m "Initial commit"', { cwd: testProjectPath }); - - return testProjectPath; -} - -export async function cleanupTestProject(projectPath: string) { - try { - fs.rmSync(projectPath, { recursive: true, force: true }); - } catch (error) { - console.error('Failed to cleanup test project:', error); - } -} \ No newline at end of file diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 756bb2da..9fc571a9 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -1,4 +1,9 @@ import { test, expect, Page } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; + +test.beforeEach(async ({ page }) => { + await installElectronApiMock(page); +}); async function dismissStartupDialogs(page: Page) { // Dismiss analytics consent dialog if present (shows before welcome) @@ -27,6 +32,7 @@ test.describe('Smoke Tests', () => { // Check that the page has loaded const title = await page.title(); expect(title).toBe('Pane'); + await expect(page.getByText('Something went wrong')).toHaveCount(0); // Take a screenshot for debugging await page.screenshot({ path: 'test-results/smoke-test.png' }); @@ -41,27 +47,26 @@ test.describe('Smoke Tests', () => { const sidebar = page.locator('[data-testid="sidebar"]').first(); await expect(sidebar).toBeVisible({ timeout: 10000 }); - // Settings button should exist (even if not immediately visible) - const settingsButton = page.locator('[data-testid="settings-button"]'); - await expect(settingsButton).toHaveCount(1); + const sidebarMenuButton = page.getByRole('button', { name: 'Sidebar menu' }); + await expect(sidebarMenuButton).toBeVisible(); }); - test('Settings button is clickable', async ({ page }) => { + test('Settings menu item is clickable', async ({ page }) => { await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await dismissStartupDialogs(page); - // Wait for the settings button to be visible - const settingsButton = page.locator('[data-testid="settings-button"]'); - await expect(settingsButton).toBeVisible({ timeout: 5000 }); + const sidebarMenuButton = page.getByRole('button', { name: 'Sidebar menu' }); + await expect(sidebarMenuButton).toBeVisible({ timeout: 5000 }); - // Verify the button is enabled and clickable - await expect(settingsButton).toBeEnabled(); + await expect(sidebarMenuButton).toBeEnabled(); + await sidebarMenuButton.click(); - // Try to click it - await settingsButton.click(); + const settingsItem = page.getByRole('button', { name: 'Settings' }); + await expect(settingsItem).toBeVisible({ timeout: 5000 }); + await settingsItem.click(); // Small wait to ensure no errors are thrown await page.waitForTimeout(500); }); -}); \ No newline at end of file +}); From 1cdd1caf0a35d57df1bd627f62ed6bc1af861e06 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:13:00 -0700 Subject: [PATCH 002/111] ci: pin Windows quality runner Use windows-2022 for main-process matrix checks to avoid the broken windows-latest VS 18 image. --- .github/workflows/quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 9baacbe1..88c8769e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -48,7 +48,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-2022] steps: - name: Checkout code From 36993b89c951b0b1b2819e1a13e1774c72104e01 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sun, 17 May 2026 14:19:49 -0700 Subject: [PATCH 003/111] fix: use target path semantics for worktree sync --- main/src/services/worktreeFileSyncService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main/src/services/worktreeFileSyncService.ts b/main/src/services/worktreeFileSyncService.ts index 6237c308..516eae38 100644 --- a/main/src/services/worktreeFileSyncService.ts +++ b/main/src/services/worktreeFileSyncService.ts @@ -11,7 +11,7 @@ import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSy * execute inside the Linux distro and need forward slashes. */ function envJoin(environment: ProjectEnvironment, ...segments: string[]): string { - if (environment === 'wsl') { + if (environment !== 'windows') { return posixJoin(...segments); } return path.join(...segments); @@ -24,7 +24,7 @@ function envJoin(environment: ProjectEnvironment, ...segments: string[]): string * use path.posix.relative instead. */ function envRelative(environment: ProjectEnvironment, from: string, to: string): string { - if (environment === 'wsl') { + if (environment !== 'windows') { return path.posix.relative(from, to); } return path.relative(from, to); @@ -89,9 +89,9 @@ async function ensureParentDir( cwd: string, environment: ProjectEnvironment, ): Promise { - const parentDir = environment === 'wsl' - ? posixJoin(...path.dirname(destPath).split(/[\\/]/)) - : path.dirname(destPath); + const parentDir = environment === 'windows' + ? path.dirname(destPath) + : path.posix.dirname(destPath); if (environment === 'windows') { // Windows: use md to create directory, suppress "already exists" error From cceb3caef50fca357caa6a49c086d564e8dad267 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 11:38:13 -0700 Subject: [PATCH 004/111] 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 7a59c51c0c61b6cf269468474fb889575a7cbf5e Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 11:39:41 -0700 Subject: [PATCH 005/111] 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'); + } + }); +}); From debeee2d9be6c3c90a1d89b441ff1da7176abf34 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:00:30 -0700 Subject: [PATCH 006/111] feat: add daemon protocol primitives --- main/src/daemon/socketFraming.test.ts | 138 ++++++++++++++++++++++ main/src/daemon/socketFraming.ts | 61 ++++++++++ main/src/daemon/socketPath.test.ts | 36 ++++++ main/src/daemon/socketPath.ts | 56 +++++++++ shared/types/daemon.ts | 159 ++++++++++++++++++++++++++ 5 files changed, 450 insertions(+) create mode 100644 main/src/daemon/socketFraming.test.ts create mode 100644 main/src/daemon/socketFraming.ts create mode 100644 main/src/daemon/socketPath.test.ts create mode 100644 main/src/daemon/socketPath.ts create mode 100644 shared/types/daemon.ts diff --git a/main/src/daemon/socketFraming.test.ts b/main/src/daemon/socketFraming.test.ts new file mode 100644 index 00000000..eb9a4802 --- /dev/null +++ b/main/src/daemon/socketFraming.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { + encodePaneDaemonFrame, + PaneDaemonFrameDecoder, +} from './socketFraming'; +import { + isDaemonOwnedChannel, + isPaneDaemonEventFrame, + isPaneDaemonFrame, + isPaneDaemonRequestFrame, + isPaneDaemonResponseFrame, + type PaneDaemonEventFrame, + type PaneDaemonRequestFrame, + type PaneDaemonResponseFrame, +} from '../../../shared/types/daemon'; + +describe('Pane daemon framing', () => { + it('encodes frames as newline-delimited JSON', () => { + const frame: PaneDaemonRequestFrame = { + type: 'request', + id: 42, + channel: 'sessions:get-all', + args: [], + }; + + expect(encodePaneDaemonFrame(frame)).toBe('{"type":"request","id":42,"channel":"sessions:get-all","args":[]}\n'); + }); + + it('decodes frames split across multiple chunks', () => { + const decoder = new PaneDaemonFrameDecoder(); + const frame: PaneDaemonEventFrame = { + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1' }], + }; + + const encoded = encodePaneDaemonFrame(frame); + + expect(decoder.push(encoded.slice(0, 12))).toEqual([]); + expect(decoder.push(encoded.slice(12))).toEqual([frame]); + expect(decoder.pendingBuffer()).toBe(''); + }); + + it('decodes multiple frames from a single chunk', () => { + const decoder = new PaneDaemonFrameDecoder(); + const first: PaneDaemonRequestFrame = { + type: 'request', + id: 1, + channel: 'projects:get-all', + args: [], + }; + const second: PaneDaemonResponseFrame = { + type: 'response', + id: 1, + ok: true, + result: [{ id: 7 }], + }; + + const frames = decoder.push(`${encodePaneDaemonFrame(first)}${encodePaneDaemonFrame(second)}`); + + expect(frames).toEqual([first, second]); + }); + + it('rejects invalid JSON frames', () => { + const decoder = new PaneDaemonFrameDecoder(); + + expect(() => decoder.push('{"type":"request"\n')).toThrow('Failed to parse Pane daemon frame'); + }); + + it('rejects frames that do not match the Pane daemon protocol', () => { + const decoder = new PaneDaemonFrameDecoder(); + + expect(() => decoder.push('{"type":"request","id":"bad","channel":"sessions:get-all","args":[]}\n')).toThrow( + 'Failed to parse Pane daemon frame: frame does not match Pane daemon protocol', + ); + }); + + it('rejects incomplete trailing frames when finishing the stream', () => { + const decoder = new PaneDaemonFrameDecoder(); + decoder.push('{"type":"request","id":1'); + + expect(() => decoder.finish()).toThrow('Incomplete Pane daemon frame at end of stream'); + }); +}); + +describe('Pane daemon shared protocol helpers', () => { + it('classifies daemon-owned channels conservatively', () => { + expect(isDaemonOwnedChannel('sessions:get-all')).toBe(true); + expect(isDaemonOwnedChannel('projects:create')).toBe(true); + expect(isDaemonOwnedChannel('git:commit')).toBe(true); + expect(isDaemonOwnedChannel('file:write')).toBe(true); + expect(isDaemonOwnedChannel('file:showInFolder')).toBe(false); + expect(isDaemonOwnedChannel('openExternal')).toBe(false); + }); + + it('detects request frames', () => { + const frame = { + type: 'request', + id: 1, + channel: 'sessions:get-all', + args: [], + }; + + expect(isPaneDaemonRequestFrame(frame)).toBe(true); + expect(isPaneDaemonFrame(frame)).toBe(true); + }); + + it('detects response frames', () => { + const successFrame = { + type: 'response', + id: 1, + ok: true, + result: { success: true }, + }; + const errorFrame = { + type: 'response', + id: 2, + ok: false, + error: { message: 'boom', code: 'ERR_TEST' }, + }; + + expect(isPaneDaemonResponseFrame(successFrame)).toBe(true); + expect(isPaneDaemonResponseFrame(errorFrame)).toBe(true); + expect(isPaneDaemonFrame(successFrame)).toBe(true); + expect(isPaneDaemonFrame(errorFrame)).toBe(true); + }); + + it('detects event frames', () => { + const frame = { + type: 'event', + channel: 'panel:created', + args: [{ id: 'panel-1' }], + }; + + expect(isPaneDaemonEventFrame(frame)).toBe(true); + expect(isPaneDaemonFrame(frame)).toBe(true); + }); +}); diff --git a/main/src/daemon/socketFraming.ts b/main/src/daemon/socketFraming.ts new file mode 100644 index 00000000..583872ba --- /dev/null +++ b/main/src/daemon/socketFraming.ts @@ -0,0 +1,61 @@ +import { isPaneDaemonFrame, type PaneDaemonFrame } from '../../../shared/types/daemon'; + +const FRAME_DELIMITER = '\n'; + +export function encodePaneDaemonFrame(frame: PaneDaemonFrame): string { + return `${JSON.stringify(frame)}${FRAME_DELIMITER}`; +} + +export class PaneDaemonFrameDecoder { + private buffer = ''; + + push(chunk: string | Buffer): PaneDaemonFrame[] { + this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + + const frames: PaneDaemonFrame[] = []; + let delimiterIndex = this.buffer.indexOf(FRAME_DELIMITER); + + while (delimiterIndex !== -1) { + const rawFrame = this.buffer.slice(0, delimiterIndex); + this.buffer = this.buffer.slice(delimiterIndex + FRAME_DELIMITER.length); + + if (rawFrame.trim().length > 0) { + frames.push(this.parseFrame(rawFrame)); + } + + delimiterIndex = this.buffer.indexOf(FRAME_DELIMITER); + } + + return frames; + } + + finish(): void { + if (this.buffer.trim().length > 0) { + throw new Error('Incomplete Pane daemon frame at end of stream'); + } + + this.buffer = ''; + } + + pendingBuffer(): string { + return this.buffer; + } + + private parseFrame(rawFrame: string): PaneDaemonFrame { + let parsed: unknown; + + try { + parsed = JSON.parse(rawFrame) as unknown; + } catch (error) { + throw new Error( + `Failed to parse Pane daemon frame: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!isPaneDaemonFrame(parsed)) { + throw new Error('Failed to parse Pane daemon frame: frame does not match Pane daemon protocol'); + } + + return parsed; + } +} diff --git a/main/src/daemon/socketPath.test.ts b/main/src/daemon/socketPath.test.ts new file mode 100644 index 00000000..6877ca92 --- /dev/null +++ b/main/src/daemon/socketPath.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getPaneDaemonEndpoint, getPaneDaemonSocketDirectory } from './socketPath'; + +describe('Pane daemon socket path', () => { + it('uses a sockets subdirectory and stable socket file on Unix-like platforms', () => { + const endpoint = getPaneDaemonEndpoint('/Users/parsa/.pane', 'darwin'); + + expect(endpoint).toEqual({ + transport: 'unix', + path: '/Users/parsa/.pane/sockets/daemon.sock', + }); + expect(getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin')).toBe('/Users/parsa/.pane/sockets'); + }); + + it('uses a stable named pipe on Windows', () => { + const endpoint = getPaneDaemonEndpoint('C:\\Users\\Parsa\\.pane', 'win32'); + + expect(endpoint.transport).toBe('pipe'); + expect(endpoint.path).toMatch(/^\\\\\.\\pipe\\pane-daemon-[0-9a-f]{16}$/); + expect(getPaneDaemonSocketDirectory('C:\\Users\\Parsa\\.pane', 'win32')).toBeNull(); + }); + + it('normalizes Windows path case before hashing the pipe name', () => { + const upper = getPaneDaemonEndpoint('C:\\Users\\Parsa\\.pane', 'win32'); + const lower = getPaneDaemonEndpoint('c:\\users\\parsa\\.pane', 'win32'); + + expect(upper).toEqual(lower); + }); + + it('resolves relative Unix paths before building the endpoint', () => { + const endpoint = getPaneDaemonEndpoint('.pane-test', 'linux'); + + expect(endpoint.transport).toBe('unix'); + expect(endpoint.path.endsWith('/sockets/daemon.sock')).toBe(true); + }); +}); diff --git a/main/src/daemon/socketPath.ts b/main/src/daemon/socketPath.ts new file mode 100644 index 00000000..76a90c00 --- /dev/null +++ b/main/src/daemon/socketPath.ts @@ -0,0 +1,56 @@ +import { createHash } from 'crypto'; +import path from 'path'; + +export interface PaneDaemonEndpoint { + transport: 'pipe' | 'unix'; + path: string; +} + +const DAEMON_SOCKET_DIRECTORY = 'sockets'; +const DAEMON_SOCKET_FILENAME = 'daemon.sock'; + +function resolveAppDirectory(appDirectory: string, platform: NodeJS.Platform): string { + if (platform === 'win32') { + return path.win32.resolve(appDirectory); + } + + return path.posix.resolve(appDirectory); +} + +function getWindowsPipeName(appDirectory: string): string { + const hash = createHash('sha256') + .update(appDirectory.toLowerCase()) + .digest('hex') + .slice(0, 16); + + return `\\\\.\\pipe\\pane-daemon-${hash}`; +} + +export function getPaneDaemonSocketDirectory(appDirectory: string, platform: NodeJS.Platform = process.platform): string | null { + const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); + if (platform === 'win32') { + return null; + } + + return path.posix.join(resolvedAppDirectory, DAEMON_SOCKET_DIRECTORY); +} + +export function getPaneDaemonEndpoint(appDirectory: string, platform: NodeJS.Platform = process.platform): PaneDaemonEndpoint { + const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); + + if (platform === 'win32') { + return { + transport: 'pipe', + path: getWindowsPipeName(resolvedAppDirectory), + }; + } + + const socketDirectory = getPaneDaemonSocketDirectory(resolvedAppDirectory, platform); + return { + transport: 'unix', + path: path.posix.join( + socketDirectory ?? resolvedAppDirectory, + DAEMON_SOCKET_FILENAME, + ), + }; +} diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts new file mode 100644 index 00000000..48f7e5b3 --- /dev/null +++ b/shared/types/daemon.ts @@ -0,0 +1,159 @@ +export interface PaneDaemonRequestFrame { + type: 'request'; + id: number; + channel: string; + args: unknown[]; +} + +export interface PaneDaemonSuccessResponseFrame { + type: 'response'; + id: number; + ok: true; + result?: unknown; +} + +export interface PaneDaemonError { + message: string; + code?: string; +} + +export interface PaneDaemonErrorResponseFrame { + type: 'response'; + id: number; + ok: false; + error: PaneDaemonError; +} + +export interface PaneDaemonEventFrame { + type: 'event'; + channel: string; + args: unknown[]; +} + +export type PaneDaemonResponseFrame = + | PaneDaemonSuccessResponseFrame + | PaneDaemonErrorResponseFrame; + +export type PaneDaemonFrame = + | PaneDaemonRequestFrame + | PaneDaemonResponseFrame + | PaneDaemonEventFrame; + +interface PaneDaemonResponseFrameCandidate { + type?: unknown; + id?: unknown; + ok?: unknown; + error?: unknown; +} + +const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ + 'file:showInFolder', +]); + +export function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + +export function isPaneDaemonRequestFrame(frame: unknown): frame is PaneDaemonRequestFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as Partial; + return ( + candidate.type === 'request' && + typeof candidate.id === 'number' && + typeof candidate.channel === 'string' && + Array.isArray(candidate.args) + ); +} + +export function isPaneDaemonResponseFrame(frame: unknown): frame is PaneDaemonResponseFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as PaneDaemonResponseFrameCandidate; + if (candidate.type !== 'response' || typeof candidate.id !== 'number' || typeof candidate.ok !== 'boolean') { + return false; + } + + if (candidate.ok === true) { + return true; + } + + if (typeof candidate.error !== 'object' || candidate.error === null) { + return false; + } + + const error = candidate.error as { message?: unknown }; + return typeof error.message === 'string'; +} + +export function isPaneDaemonEventFrame(frame: unknown): frame is PaneDaemonEventFrame { + if (typeof frame !== 'object' || frame === null) { + return false; + } + + const candidate = frame as Partial; + return ( + candidate.type === 'event' && + typeof candidate.channel === 'string' && + Array.isArray(candidate.args) + ); +} + +export function isPaneDaemonFrame(frame: unknown): frame is PaneDaemonFrame { + return ( + isPaneDaemonRequestFrame(frame) || + isPaneDaemonResponseFrame(frame) || + isPaneDaemonEventFrame(frame) + ); +} From 2b5440eecdd519155d27081f9ed93c93b6bb74a1 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:25:14 -0700 Subject: [PATCH 007/111] fix: preserve UTF-8 daemon socket frames --- main/src/daemon/socketFraming.test.ts | 18 ++++++++++++++++++ main/src/daemon/socketFraming.ts | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/main/src/daemon/socketFraming.test.ts b/main/src/daemon/socketFraming.test.ts index eb9a4802..c91c93fe 100644 --- a/main/src/daemon/socketFraming.test.ts +++ b/main/src/daemon/socketFraming.test.ts @@ -41,6 +41,24 @@ describe('Pane daemon framing', () => { expect(decoder.pendingBuffer()).toBe(''); }); + it('preserves multibyte UTF-8 characters split across buffer chunks', () => { + const decoder = new PaneDaemonFrameDecoder(); + const frame: PaneDaemonEventFrame = { + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1', label: 'Pane café 日本語' }], + }; + + const encodedBuffer = Buffer.from(encodePaneDaemonFrame(frame), 'utf8'); + const multibyteChar = Buffer.from('é', 'utf8'); + const splitIndex = encodedBuffer.indexOf(multibyteChar) + 1; + expect(splitIndex).toBeGreaterThan(0); + + expect(decoder.push(encodedBuffer.subarray(0, splitIndex))).toEqual([]); + expect(decoder.push(encodedBuffer.subarray(splitIndex))).toEqual([frame]); + expect(decoder.pendingBuffer()).toBe(''); + }); + it('decodes multiple frames from a single chunk', () => { const decoder = new PaneDaemonFrameDecoder(); const first: PaneDaemonRequestFrame = { diff --git a/main/src/daemon/socketFraming.ts b/main/src/daemon/socketFraming.ts index 583872ba..62e6d6d5 100644 --- a/main/src/daemon/socketFraming.ts +++ b/main/src/daemon/socketFraming.ts @@ -1,3 +1,4 @@ +import { StringDecoder } from 'string_decoder'; import { isPaneDaemonFrame, type PaneDaemonFrame } from '../../../shared/types/daemon'; const FRAME_DELIMITER = '\n'; @@ -8,9 +9,10 @@ export function encodePaneDaemonFrame(frame: PaneDaemonFrame): string { export class PaneDaemonFrameDecoder { private buffer = ''; + private decoder = new StringDecoder('utf8'); push(chunk: string | Buffer): PaneDaemonFrame[] { - this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk); const frames: PaneDaemonFrame[] = []; let delimiterIndex = this.buffer.indexOf(FRAME_DELIMITER); @@ -30,11 +32,14 @@ export class PaneDaemonFrameDecoder { } finish(): void { + this.buffer += this.decoder.end(); + if (this.buffer.trim().length > 0) { throw new Error('Incomplete Pane daemon frame at end of stream'); } this.buffer = ''; + this.decoder = new StringDecoder('utf8'); } pendingBuffer(): string { From 6dcc1dcf892420d4b955dbc3d53b12fadaaf9c0b Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:08:32 -0700 Subject: [PATCH 008/111] feat: add pane command registry core --- main/src/daemon/commandRegistry.test.ts | 56 +++++++++++++++ main/src/daemon/commandRegistry.ts | 65 ++++++++++++++++++ main/src/ipc/folders.ts | 90 +++++++++++++------------ main/src/ipc/git.ts | 6 +- main/src/ipc/index.ts | 15 +++-- main/src/ipc/logs.ts | 67 ++++++++++-------- main/src/ipc/resourceMonitor.ts | 21 ++++-- main/src/ipc/session.ts | 4 +- main/src/services/folderEvents.ts | 26 +++++++ main/src/services/taskQueue.ts | 11 ++- 10 files changed, 269 insertions(+), 92 deletions(-) create mode 100644 main/src/daemon/commandRegistry.test.ts create mode 100644 main/src/daemon/commandRegistry.ts create mode 100644 main/src/services/folderEvents.ts diff --git a/main/src/daemon/commandRegistry.test.ts b/main/src/daemon/commandRegistry.test.ts new file mode 100644 index 00000000..d7ef141d --- /dev/null +++ b/main/src/daemon/commandRegistry.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { PaneCommandRegistry } from './commandRegistry'; + +describe('PaneCommandRegistry', () => { + it('registers and invokes daemon-owned commands', async () => { + const registry = new PaneCommandRegistry(); + registry.register('folders:get-by-project', async (projectId: number) => ({ projectId })); + + await expect(registry.invoke('folders:get-by-project', [42])).resolves.toEqual({ projectId: 42 }); + }); + + it('rejects non-daemon-owned channels', () => { + const registry = new PaneCommandRegistry(); + + expect(() => registry.register('openExternal', () => true)).toThrow( + 'Cannot register non-daemon-owned channel "openExternal" in PaneCommandRegistry', + ); + }); + + it('rejects duplicate registrations', () => { + const registry = new PaneCommandRegistry(); + registry.register('logs:get-by-project', () => []); + + expect(() => registry.register('logs:get-by-project', () => [])).toThrow( + 'Pane daemon command "logs:get-by-project" is already registered', + ); + }); + + it('throws when invoking an unregistered command', async () => { + const registry = new PaneCommandRegistry(); + + await expect(registry.invoke('folders:get-by-project', [1])).rejects.toThrow( + 'No Pane daemon command registered for channel "folders:get-by-project"', + ); + }); + + it('binds registered commands back to IPC handles', async () => { + const registry = new PaneCommandRegistry(); + const bound = new Map unknown>(); + const ipcMain = { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown) { + bound.set(channel, listener); + }, + }; + + registry.register('resource-monitor:get-snapshot', async () => ({ success: true })); + registry.bindChannels(ipcMain, ['resource-monitor:get-snapshot']); + + const listener = bound.get('resource-monitor:get-snapshot'); + expect(listener).toBeTruthy(); + if (!listener) { + throw new Error('Expected IPC listener to be bound'); + } + await expect(listener({})).resolves.toEqual({ success: true }); + }); +}); diff --git a/main/src/daemon/commandRegistry.ts b/main/src/daemon/commandRegistry.ts new file mode 100644 index 00000000..2de857f4 --- /dev/null +++ b/main/src/daemon/commandRegistry.ts @@ -0,0 +1,65 @@ +import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; + +export type PaneCommandHandler = ( + ...args: TArgs +) => Promise | TResult; + +interface IpcMainHandleLike { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; +} + +export class PaneCommandRegistry { + private readonly handlers = new Map(); + private readonly boundChannels = new Set(); + + register( + channel: string, + handler: PaneCommandHandler, + ): void { + if (!isDaemonOwnedChannel(channel)) { + throw new Error(`Cannot register non-daemon-owned channel "${channel}" in PaneCommandRegistry`); + } + + if (this.handlers.has(channel)) { + throw new Error(`Pane daemon command "${channel}" is already registered`); + } + + this.handlers.set(channel, handler as PaneCommandHandler); + } + + has(channel: string): boolean { + return this.handlers.has(channel); + } + + listChannels(): string[] { + return [...this.handlers.keys()].sort(); + } + + async invoke(channel: string, args: readonly unknown[] = []): Promise { + const handler = this.handlers.get(channel); + if (!handler) { + throw new Error(`No Pane daemon command registered for channel "${channel}"`); + } + + return handler(...args); + } + + bindChannel(ipcMain: IpcMainHandleLike, channel: string): void { + if (!this.handlers.has(channel)) { + throw new Error(`Cannot bind unregistered Pane daemon command "${channel}"`); + } + + if (this.boundChannels.has(channel)) { + throw new Error(`Pane daemon command "${channel}" is already bound to IPC`); + } + + ipcMain.handle(channel, (_event, ...args) => this.invoke(channel, args)); + this.boundChannels.add(channel); + } + + bindChannels(ipcMain: IpcMainHandleLike, channels: readonly string[]): void { + for (const channel of channels) { + this.bindChannel(ipcMain, channel); + } + } +} diff --git a/main/src/ipc/folders.ts b/main/src/ipc/folders.ts index aa02d8bb..7810307f 100644 --- a/main/src/ipc/folders.ts +++ b/main/src/ipc/folders.ts @@ -1,28 +1,31 @@ -import { IpcMain } from 'electron'; -import type { Folder } from '../database/models'; +import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; - -// Convert database folder (snake_case) to frontend folder (camelCase) -export function convertDbFolderToFolder(dbFolder: Folder) { - return { - id: dbFolder.id, - name: dbFolder.name, - projectId: dbFolder.project_id, - parentFolderId: dbFolder.parent_folder_id, - displayOrder: dbFolder.display_order, - createdAt: dbFolder.created_at, - updatedAt: dbFolder.updated_at - }; -} - -export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) { - const { databaseService, getMainWindow, analyticsManager } = services; +import { + convertDbFolderToRendererFolder, + emitFolderCreatedEvent, + emitFolderDeletedEvent, + emitFolderUpdatedEvent, +} from '../services/folderEvents'; + +const DAEMON_FOLDER_CHANNELS = [ + 'folders:get-by-project', + 'folders:create', + 'folders:update', + 'folders:delete', + 'folders:reorder', + 'folders:move-session', + 'folders:move', +] as const; + +export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices, commandRegistry: PaneCommandRegistry) { + const { databaseService, analyticsManager } = services; // Get all folders for a project - ipcMain.handle('folders:get-by-project', async (_, projectId: number) => { + commandRegistry.register('folders:get-by-project', async (projectId: number) => { try { const folders = databaseService.getFoldersForProject(projectId); - const convertedFolders = folders.map(convertDbFolderToFolder); + const convertedFolders = folders.map(convertDbFolderToRendererFolder); return { success: true, data: convertedFolders }; } catch (error: unknown) { console.error('[IPC] Failed to get folders:', error); @@ -31,10 +34,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Create a new folder - ipcMain.handle('folders:create', async (_, name: string, projectId: number, parentFolderId?: string | null) => { + commandRegistry.register('folders:create', async (name: string, projectId: number, parentFolderId?: string | null) => { try { const folder = databaseService.createFolder(name, projectId, parentFolderId); - const convertedFolder = convertDbFolderToFolder(folder); + const convertedFolder = convertDbFolderToRendererFolder(folder); // Track folder creation if (analyticsManager) { @@ -44,6 +47,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); } + emitFolderCreatedEvent(folder); return { success: true, data: convertedFolder }; } catch (error: unknown) { console.error('[IPC] Failed to create folder:', error); @@ -52,7 +56,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Update a folder - ipcMain.handle('folders:update', async (_, folderId: string, updates: { name?: string; display_order?: number; parent_folder_id?: string | null }) => { + commandRegistry.register('folders:update', async ( + folderId: string, + updates: { name?: string; display_order?: number; parent_folder_id?: string | null }, + ) => { try { // Track folder rename if name is being updated if (analyticsManager && updates.name !== undefined) { @@ -64,14 +71,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) // Get the updated folder to emit the event const updatedFolder = databaseService.getFolder(folderId); if (updatedFolder) { - - // Emit the folder:updated event to notify the frontend - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - console.log(`[IPC] Emitting folder:updated event for folder ${folderId}`); - const convertedFolder = convertDbFolderToFolder(updatedFolder); - mainWindow.webContents.send('folder:updated', convertedFolder); - } + emitFolderUpdatedEvent(updatedFolder); } return { success: true }; @@ -82,7 +82,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Delete a folder - ipcMain.handle('folders:delete', async (_, folderId: string) => { + commandRegistry.register('folders:delete', async (folderId: string) => { try { // Count sessions in the folder before deletion for analytics if (analyticsManager) { @@ -100,12 +100,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) databaseService.deleteFolder(folderId); - // Emit the folder:deleted event to notify the frontend - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - console.log(`[IPC] Emitting folder:deleted event for folder ${folderId}`); - mainWindow.webContents.send('folder:deleted', folderId); - } + emitFolderDeletedEvent(folderId); return { success: true }; } catch (error: unknown) { @@ -115,7 +110,10 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Reorder folders within a project - ipcMain.handle('folders:reorder', async (_, projectId: number, folderOrders: Array<{ id: string; displayOrder: number }>) => { + commandRegistry.register('folders:reorder', async ( + projectId: number, + folderOrders: Array<{ id: string; displayOrder: number }>, + ) => { try { databaseService.reorderFolders(projectId, folderOrders); return { success: true }; @@ -126,7 +124,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Move session to folder - ipcMain.handle('folders:move-session', async (_, sessionId: string, folderId: string | null) => { + commandRegistry.register('folders:move-session', async (sessionId: string, folderId: string | null) => { try { // Get the session to verify it exists const session = databaseService.getSession(sessionId); @@ -155,7 +153,7 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) }); // Move folder to another folder (for nesting) - ipcMain.handle('folders:move', async (_, folderId: string, parentFolderId: string | null) => { + commandRegistry.register('folders:move', async (folderId: string, parentFolderId: string | null) => { try { // Get the folder to verify it exists const folder = databaseService.getFolder(folderId); @@ -187,10 +185,18 @@ export function registerFolderHandlers(ipcMain: IpcMain, services: AppServices) // Update the folder databaseService.updateFolder(folderId, { parent_folder_id: parentFolderId }); + + const updatedFolder = databaseService.getFolder(folderId); + if (updatedFolder) { + emitFolderUpdatedEvent(updatedFolder); + } + return { success: true }; } catch (error: unknown) { console.error('[IPC] Failed to move folder:', error); return { success: false, error: error instanceof Error ? error.message : 'Failed to move folder' }; } }); -} \ No newline at end of file + + commandRegistry.bindChannels(ipcMain, DAEMON_FOLDER_CHANNELS); +} diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 1f517cc6..d023d62c 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -3,7 +3,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import type { AppServices } from './types'; import { buildGitCommitCommand } from '../utils/shellEscape'; -import { mainWindow } from '../index'; +import { getPaneEventSink } from '../core/runtime'; import { panelEventBus } from '../services/panelEventBus'; import { PanelEventType, ToolPanelType, PanelEvent } from '../../../shared/types/panels'; import type { Session } from '../types/session'; @@ -100,9 +100,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo // Also forward to renderer so UI components listening for window 'panel:event' receive it try { - if (mainWindow) { - mainWindow.webContents.send('panel:event', event); - } + getPaneEventSink().send('panel:event', event); } catch (ipcError) { console.error('[Git] Failed to forward git operation event to renderer:', ipcError); } diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 79100312..87188dfe 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -22,9 +22,12 @@ import { registerCloudHandlers } from './cloud'; import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; -export function registerIpcHandlers(services: AppServices): void { +export function registerIpcHandlers(services: AppServices): PaneCommandRegistry { + const commandRegistry = new PaneCommandRegistry(); + registerAppHandlers(ipcMain, services); registerUpdaterHandlers(ipcMain, services); registerSessionHandlers(ipcMain, services); @@ -35,16 +38,18 @@ export function registerIpcHandlers(services: AppServices): void { registerScriptHandlers(ipcMain, services); registerPromptHandlers(ipcMain, services); registerFileHandlers(ipcMain, services); - registerFolderHandlers(ipcMain, services); + registerFolderHandlers(ipcMain, services, commandRegistry); registerUIStateHandlers(services); registerDashboardHandlers(ipcMain, services); - setupLogHandlers(services.sessionManager); + setupLogHandlers(ipcMain, services.sessionManager, commandRegistry); registerPanelHandlers(ipcMain, services); registerEditorPanelHandlers(ipcMain, services); registerNimbalystHandlers(ipcMain, services); registerSpotlightHandlers(ipcMain, services); registerCloudHandlers(ipcMain, services); registerClipboardHandlers(ipcMain, services); - registerResourceMonitorHandlers(ipcMain, services); + registerResourceMonitorHandlers(ipcMain, services, commandRegistry); registerOnboardingHandlers(ipcMain, services); -} \ No newline at end of file + + return commandRegistry; +} diff --git a/main/src/ipc/logs.ts b/main/src/ipc/logs.ts index e9b94007..083ae630 100644 --- a/main/src/ipc/logs.ts +++ b/main/src/ipc/logs.ts @@ -1,6 +1,7 @@ -import { ipcMain } from 'electron'; +import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { getPaneEventSink } from '../core/runtime'; import { SessionManager } from '../services/sessionManager'; -import { mainWindow } from '../index'; interface LogEntry { timestamp: string; @@ -12,9 +13,30 @@ interface LogEntry { // Store logs per session in memory const sessionLogs = new Map(); -export function setupLogHandlers(sessionManager: SessionManager) { +const DAEMON_LOG_CHANNELS = [ + 'sessions:get-logs', + 'sessions:clear-logs', + 'sessions:add-log', +] as const; + +function sendSessionLogEvent(sessionId: string, entry: LogEntry): void { + getPaneEventSink().send('session-log', { + sessionId, + entry, + }); +} + +function sendSessionLogsClearedEvent(sessionId: string): void { + getPaneEventSink().send('session-logs-cleared', { sessionId }); +} + +export function setupLogHandlers( + ipcMain: IpcMain, + _sessionManager: SessionManager, + commandRegistry: PaneCommandRegistry, +) { // Get logs for a session - ipcMain.handle('sessions:get-logs', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-logs', async (sessionId: string) => { try { const logs = sessionLogs.get(sessionId) || []; return { success: true, data: logs }; @@ -28,9 +50,10 @@ export function setupLogHandlers(sessionManager: SessionManager) { }); // Clear logs for a session - ipcMain.handle('sessions:clear-logs', async (_event, sessionId: string) => { + commandRegistry.register('sessions:clear-logs', async (sessionId: string) => { try { sessionLogs.set(sessionId, []); + sendSessionLogsClearedEvent(sessionId); return { success: true }; } catch (error) { console.error('Failed to clear logs:', error); @@ -42,20 +65,13 @@ export function setupLogHandlers(sessionManager: SessionManager) { }); // Add a log entry - ipcMain.handle('sessions:add-log', async (_event, sessionId: string, entry: LogEntry) => { + commandRegistry.register('sessions:add-log', async (sessionId: string, entry: LogEntry) => { try { const logs = sessionLogs.get(sessionId) || []; logs.push(entry); sessionLogs.set(sessionId, logs); - - // Send the log entry to the renderer - if (mainWindow) { - mainWindow.webContents.send('session-log', { - sessionId, - entry - }); - } - + + sendSessionLogEvent(sessionId, entry); return { success: true }; } catch (error) { console.error('Failed to add log:', error); @@ -65,6 +81,8 @@ export function setupLogHandlers(sessionManager: SessionManager) { }; } }); + + commandRegistry.bindChannels(ipcMain, DAEMON_LOG_CHANNELS); } // Helper function to add a log from internal sources @@ -79,22 +97,13 @@ export function addSessionLog(sessionId: string, level: LogEntry['level'], messa const logs = sessionLogs.get(sessionId) || []; logs.push(entry); sessionLogs.set(sessionId, logs); - - // Send the log entry to the renderer - if (mainWindow) { - mainWindow.webContents.send('session-log', { - sessionId, - entry - }); - } + + sendSessionLogEvent(sessionId, entry); } // Helper to clean up logs when a session is deleted or when starting a new run export function cleanupSessionLogs(sessionId: string) { sessionLogs.delete(sessionId); - - // Notify the frontend to clear logs - if (mainWindow) { - mainWindow.webContents.send('session-logs-cleared', { sessionId }); - } -} \ No newline at end of file + + sendSessionLogsClearedEvent(sessionId); +} diff --git a/main/src/ipc/resourceMonitor.ts b/main/src/ipc/resourceMonitor.ts index 551c6803..ad6dcc8e 100644 --- a/main/src/ipc/resourceMonitor.ts +++ b/main/src/ipc/resourceMonitor.ts @@ -1,9 +1,20 @@ import type { IpcMain } from 'electron'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; import { resourceMonitorService } from '../services/resourceMonitorService'; -export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: AppServices): void { - ipcMain.handle('resource-monitor:get-snapshot', async () => { +const DAEMON_RESOURCE_MONITOR_CHANNELS = [ + 'resource-monitor:get-snapshot', + 'resource-monitor:start-active', + 'resource-monitor:stop-active', +] as const; + +export function registerResourceMonitorHandlers( + ipcMain: IpcMain, + _services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { + commandRegistry.register('resource-monitor:get-snapshot', async () => { try { const snapshot = await resourceMonitorService.getSnapshot(); return { success: true, data: snapshot }; @@ -13,7 +24,7 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App } }); - ipcMain.handle('resource-monitor:start-active', async () => { + commandRegistry.register('resource-monitor:start-active', async () => { try { resourceMonitorService.startActivePolling(); return { success: true }; @@ -22,7 +33,7 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App } }); - ipcMain.handle('resource-monitor:stop-active', async () => { + commandRegistry.register('resource-monitor:stop-active', async () => { try { resourceMonitorService.stopActivePolling(); return { success: true }; @@ -30,4 +41,6 @@ export function registerResourceMonitorHandlers(ipcMain: IpcMain, _services: App return { success: false, error: (error instanceof Error) ? error.message : String(error) }; } }); + + commandRegistry.bindChannels(ipcMain, DAEMON_RESOURCE_MONITOR_CHANNELS); } diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index 4eb3f684..a6be5f53 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -11,7 +11,7 @@ import { existsSync } from 'fs'; import type { AppServices } from './types'; import type { CreateSessionRequest } from '../types/session'; import { getAppSubdirectory } from '../utils/appDirectory'; -import { convertDbFolderToFolder } from './folders'; +import { convertDbFolderToRendererFolder } from '../services/folderEvents'; import { sessionImageCounters } from './panels'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; @@ -89,7 +89,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) const projectsWithSessions = allProjects.map(project => { const sessions = sessionManager.getSessionsForProject(project.id); const folders = databaseService.getFoldersForProject(project.id); - const convertedFolders = folders.map(convertDbFolderToFolder); + const convertedFolders = folders.map(convertDbFolderToRendererFolder); return { ...project, sessions, diff --git a/main/src/services/folderEvents.ts b/main/src/services/folderEvents.ts new file mode 100644 index 00000000..453af1e0 --- /dev/null +++ b/main/src/services/folderEvents.ts @@ -0,0 +1,26 @@ +import { getPaneEventSink } from '../core/runtime'; +import type { Folder as DatabaseFolder } from '../database/models'; + +export function convertDbFolderToRendererFolder(dbFolder: DatabaseFolder) { + return { + id: dbFolder.id, + name: dbFolder.name, + projectId: dbFolder.project_id, + parentFolderId: dbFolder.parent_folder_id, + displayOrder: dbFolder.display_order, + createdAt: dbFolder.created_at, + updatedAt: dbFolder.updated_at, + }; +} + +export function emitFolderCreatedEvent(folder: DatabaseFolder): void { + getPaneEventSink().send('folder:created', convertDbFolderToRendererFolder(folder)); +} + +export function emitFolderUpdatedEvent(folder: DatabaseFolder): void { + getPaneEventSink().send('folder:updated', convertDbFolderToRendererFolder(folder)); +} + +export function emitFolderDeletedEvent(folderId: string): void { + getPaneEventSink().send('folder:deleted', folderId); +} diff --git a/main/src/services/taskQueue.ts b/main/src/services/taskQueue.ts index f59a10e8..1a4a1c4c 100644 --- a/main/src/services/taskQueue.ts +++ b/main/src/services/taskQueue.ts @@ -17,6 +17,7 @@ import type { Project } from '../database/models'; import { worktreeFileSyncService } from './worktreeFileSyncService'; import { terminalPanelManager } from './terminalPanelManager'; import { detectProjectConfig } from './projectConfigDetector'; +import { emitFolderCreatedEvent } from './folderEvents'; interface TaskQueueOptions { sessionManager: SessionManager; @@ -503,12 +504,10 @@ export class TaskQueue { folderId = folder.id; // Emit folder created event immediately - const getMainWindow = this.options.getMainWindow; - const mainWindow = getMainWindow(); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('folder:created', folder); - } else { - console.warn(`[TaskQueue] Could not emit folder:created event - main window not available`); + try { + emitFolderCreatedEvent(folder); + } catch (error) { + console.error('[TaskQueue] Failed to emit folder:created event:', error); } } catch (error) { console.error('[TaskQueue] Failed to create folder for multi-session prompt:', error); From 2df25a17c763e7e0a9675ed1b949a2da4c026309 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:16:57 -0700 Subject: [PATCH 009/111] feat: route data-domain IPC through command registry --- main/src/ipc/daemonRegistryBindings.test.ts | 121 ++++++++++++++++++++ main/src/ipc/file.ts | 78 +++++++++---- main/src/ipc/index.ts | 6 +- main/src/ipc/project.ts | 58 +++++++--- main/src/ipc/prompt.ts | 25 +++- 5 files changed, 239 insertions(+), 49 deletions(-) create mode 100644 main/src/ipc/daemonRegistryBindings.test.ts diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts new file mode 100644 index 00000000..7d39b37e --- /dev/null +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { registerFileHandlers } from './file'; +import { registerProjectHandlers } from './project'; +import { registerPromptHandlers } from './prompt'; +import type { AppServices } from './types'; + +vi.mock('../services/panelManager', () => ({ + panelManager: {}, +})); + +const PROJECT_CHANNELS = [ + 'projects:get-all', + 'projects:get-active', + 'projects:create', + 'projects:activate', + 'projects:update', + 'projects:delete', + 'projects:reorder', + 'projects:detect-branch', + 'projects:list-branches', + 'projects:refresh-git-status', + 'projects:get-running-script', + 'projects:stop-script', + 'projects:detect-config', + 'projects:resolve-run-script', + 'projects:run-script', +] as const; + +const PROMPT_CHANNELS = [ + 'sessions:get-prompts', + 'prompts:get-all', + 'prompts:get-by-id', +] as const; + +const FILE_CHANNELS = [ + 'file:read', + 'file:read-binary', + 'file:exists', + 'file:write', + 'file:write-binary', + 'file:getPath', + 'git:commit', + 'git:revert', + 'git:restore', + 'file:readAtRevision', + 'file:list', + 'file:delete', + 'file:rename', + 'file:move', + 'file:copy', + 'file:duplicate', + 'file:search', + 'file:read-project', + 'file:write-project', + 'git:execute-project', + 'file:resolveAbsolutePath', +] as const; + +interface IpcMainStub { + boundChannels: string[]; + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; +} + +function createIpcMainStub(): IpcMainStub { + const boundChannels: string[] = []; + + return { + boundChannels, + handle(channel: string) { + boundChannels.push(channel); + }, + }; +} + +function createServicesStub(): AppServices { + return { + sessionManager: {}, + gitStatusManager: {}, + configManager: {}, + databaseService: {}, + worktreeManager: {}, + analyticsManager: {}, + } as AppServices; +} + +describe('daemon registry IPC bindings', () => { + it('binds daemon-owned project channels through the shared registry', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerProjectHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...PROJECT_CHANNELS].sort()); + expect(ipcMain.boundChannels.sort()).toEqual([...PROJECT_CHANNELS].sort()); + }); + + it('binds daemon-owned prompt channels through the shared registry', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerPromptHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...PROMPT_CHANNELS].sort()); + expect(ipcMain.boundChannels.sort()).toEqual([...PROMPT_CHANNELS].sort()); + }); + + it('keeps file manager shell adapters outside the daemon registry surface', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerFileHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...FILE_CHANNELS].sort()); + expect(ipcMain.boundChannels).toContain('file:showInFolder'); + expect(ipcMain.boundChannels.filter(channel => channel !== 'file:showInFolder').sort()).toEqual( + [...FILE_CHANNELS].sort(), + ); + expect(registry.has('file:showInFolder')).toBe(false); + }); +}); diff --git a/main/src/ipc/file.ts b/main/src/ipc/file.ts index bb3d746e..9ae70d05 100644 --- a/main/src/ipc/file.ts +++ b/main/src/ipc/file.ts @@ -1,9 +1,11 @@ -import { IpcMain, shell } from 'electron'; +import type { IpcMain } from 'electron'; +import { shell } from 'electron'; import * as fs from 'fs/promises'; import * as fsSync from 'fs'; import * as path from 'path'; import { execFileSync } from 'child_process'; import { glob } from 'glob'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; import type { Session } from '../types/session'; import { GIT_ATTRIBUTION_ENV } from '../utils/attribution'; @@ -91,7 +93,35 @@ interface FileSearchRequest { limit?: number; } -export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): void { +const DAEMON_FILE_CHANNELS = [ + 'file:read', + 'file:read-binary', + 'file:exists', + 'file:write', + 'file:write-binary', + 'file:getPath', + 'git:commit', + 'git:revert', + 'git:restore', + 'file:readAtRevision', + 'file:list', + 'file:delete', + 'file:rename', + 'file:move', + 'file:copy', + 'file:duplicate', + 'file:search', + 'file:read-project', + 'file:write-project', + 'git:execute-project', + 'file:resolveAbsolutePath', +] as const; + +export function registerFileHandlers( + ipcMain: IpcMain, + services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { const { sessionManager, gitStatusManager, configManager } = services; async function resolveWorktreePath(sessionId: string, relativePath = ''): Promise<{ @@ -153,7 +183,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v } // Read file contents from a session's worktree - ipcMain.handle('file:read', async (_, request: FileReadRequest) => { + commandRegistry.register('file:read', async (request: FileReadRequest) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -191,7 +221,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Read a file as binary (base64-encoded) — used for image/PDF preview - ipcMain.handle('file:read-binary', async (_, request: FileReadRequest) => { + commandRegistry.register('file:read-binary', async (request: FileReadRequest) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -226,7 +256,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Check if a file exists in a session's worktree - ipcMain.handle('file:exists', async (_, request: FilePathRequest) => { + commandRegistry.register('file:exists', async (request: FilePathRequest) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -258,7 +288,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Write file contents to a session's worktree - ipcMain.handle('file:write', async (_, request: FileWriteRequest) => { + commandRegistry.register('file:write', async (request: FileWriteRequest) => { try { // Removed verbose logging of file:write requests to reduce console noise during auto-save @@ -321,7 +351,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v } // Write binary file to a session's worktree root (for drag-and-drop uploads) - ipcMain.handle('file:write-binary', async (_, request: FileWriteBinaryRequest) => { + commandRegistry.register('file:write-binary', async (request: FileWriteBinaryRequest) => { try { if (!request.fileName) { throw new Error('File name is required'); @@ -396,7 +426,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Get the full path for a file in a session's worktree - ipcMain.handle('file:getPath', async (_, request: FilePathRequest) => { + commandRegistry.register('file:getPath', async (request: FilePathRequest) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -426,7 +456,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Commit changes in a session's worktree - ipcMain.handle('git:commit', async (_, request: { sessionId: string; message: string }) => { + commandRegistry.register('git:commit', async (request: { sessionId: string; message: string }) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -510,7 +540,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Revert a specific commit - ipcMain.handle('git:revert', async (_, request: { sessionId: string; commitHash: string }) => { + commandRegistry.register('git:revert', async (request: { sessionId: string; commitHash: string }) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -544,7 +574,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Restore all uncommitted changes - ipcMain.handle('git:restore', async (_, request: { sessionId: string }) => { + commandRegistry.register('git:restore', async (request: { sessionId: string }) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -576,7 +606,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Read file contents at a specific git revision - ipcMain.handle('file:readAtRevision', async (_, request: { sessionId: string; filePath: string; revision?: string }) => { + commandRegistry.register('file:readAtRevision', async (request: { sessionId: string; filePath: string; revision?: string }) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -623,7 +653,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // List files and directories in a session's worktree - ipcMain.handle('file:list', async (_, request: FileListRequest) => { + commandRegistry.register('file:list', async (request: FileListRequest) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) { @@ -703,7 +733,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Delete a file from a session's worktree - ipcMain.handle('file:delete', async (_, request: FileDeleteRequest) => { + commandRegistry.register('file:delete', async (request: FileDeleteRequest) => { try { const { fullPath, normalizedPath } = await resolveWorktreePath(request.sessionId, request.filePath); @@ -745,7 +775,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Rename a file or folder within a session's worktree - ipcMain.handle('file:rename', async (_, request: FileRenameRequest) => { + commandRegistry.register('file:rename', async (request: FileRenameRequest) => { try { const { basePath, fullPath, normalizedPath } = await resolveWorktreePath(request.sessionId, request.filePath); const newName = validateSimpleName(request.newName); @@ -774,7 +804,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Move a file or folder into a target directory within a session's worktree - ipcMain.handle('file:move', async (_, request: FileMoveRequest) => { + commandRegistry.register('file:move', async (request: FileMoveRequest) => { try { const source = await resolveWorktreePath(request.sessionId, request.sourcePath); const target = await resolveWorktreePath(request.sessionId, request.targetDir); @@ -807,7 +837,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Copy a file or folder into a target directory within a session's worktree - ipcMain.handle('file:copy', async (_, request: FileCopyRequest) => { + commandRegistry.register('file:copy', async (request: FileCopyRequest) => { try { const source = await resolveWorktreePath(request.sessionId, request.sourcePath); const target = await resolveWorktreePath(request.sessionId, request.targetDir); @@ -833,7 +863,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Duplicate a file or folder next to itself within a session's worktree - ipcMain.handle('file:duplicate', async (_, request: FilePathRequest) => { + commandRegistry.register('file:duplicate', async (request: FilePathRequest) => { try { const source = await resolveWorktreePath(request.sessionId, request.filePath); const parentDir = path.dirname(source.fullPath); @@ -855,7 +885,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Search for files matching a pattern - ipcMain.handle('file:search', async (_, request: FileSearchRequest) => { + commandRegistry.register('file:search', async (request: FileSearchRequest) => { try { // Determine the search directory and get path resolver // storedDir = Linux path (for CommandRunner cwd), searchDirectory = filesystem path (for fs ops) @@ -1023,7 +1053,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Read file from project directory (not worktree) - ipcMain.handle('file:read-project', async (_, request: { projectId: number; filePath: string }) => { + commandRegistry.register('file:read-project', async (request: { projectId: number; filePath: string }) => { console.log('[file:read-project] Request:', request); try { const ctx = sessionManager.getProjectContextByProjectId(request.projectId); @@ -1069,7 +1099,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Write file to project directory (not worktree) - ipcMain.handle('file:write-project', async (_, request: { projectId: number; filePath: string; content: string }) => { + commandRegistry.register('file:write-project', async (request: { projectId: number; filePath: string; content: string }) => { console.log('[file:write-project] Request:', { projectId: request.projectId, filePath: request.filePath, contentLength: request.content.length }); try { const ctx = sessionManager.getProjectContextByProjectId(request.projectId); @@ -1110,7 +1140,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Execute git command in project directory - ipcMain.handle('git:execute-project', async (_, request: { projectId: number; args: string[] }) => { + commandRegistry.register('git:execute-project', async (request: { projectId: number; args: string[] }) => { console.log('[git:execute-project] Request:', request); try { const ctx = sessionManager.getProjectContextByProjectId(request.projectId); @@ -1155,7 +1185,7 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v }); // Resolve an absolute filesystem path for a file in a session's worktree - ipcMain.handle('file:resolveAbsolutePath', async (_, request: { sessionId: string; path?: string }) => { + commandRegistry.register('file:resolveAbsolutePath', async (request: { sessionId: string; path?: string }) => { try { const session = sessionManager.getSession(request.sessionId); if (!session) throw new Error(`Session not found: ${request.sessionId}`); @@ -1180,6 +1210,8 @@ export function registerFileHandlers(ipcMain: IpcMain, services: AppServices): v } }); + commandRegistry.bindChannels(ipcMain, DAEMON_FILE_CHANNELS); + // Show a file/folder from a session's worktree in the native file manager ipcMain.handle('file:showInFolder', async (_, request: { sessionId: string; path?: string }) => { try { diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 87188dfe..ba25b2ac 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -31,13 +31,13 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerAppHandlers(ipcMain, services); registerUpdaterHandlers(ipcMain, services); registerSessionHandlers(ipcMain, services); - registerProjectHandlers(ipcMain, services); + registerProjectHandlers(ipcMain, services, commandRegistry); registerConfigHandlers(ipcMain, services); registerDialogHandlers(ipcMain, services); registerGitHandlers(ipcMain, services); registerScriptHandlers(ipcMain, services); - registerPromptHandlers(ipcMain, services); - registerFileHandlers(ipcMain, services); + registerPromptHandlers(ipcMain, services, commandRegistry); + registerFileHandlers(ipcMain, services, commandRegistry); registerFolderHandlers(ipcMain, services, commandRegistry); registerUIStateHandlers(services); registerDashboardHandlers(ipcMain, services); diff --git a/main/src/ipc/project.ts b/main/src/ipc/project.ts index 2a5cbfc9..539b3445 100644 --- a/main/src/ipc/project.ts +++ b/main/src/ipc/project.ts @@ -1,6 +1,7 @@ -import { IpcMain } from 'electron'; +import type { IpcMain } from 'electron'; import { mkdir, access } from 'fs/promises'; import path from 'path'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; import type { CreateProjectRequest, UpdateProjectRequest } from '../../../frontend/src/types/project'; import { scriptExecutionTracker } from '../services/scriptExecutionTracker'; @@ -50,10 +51,32 @@ async function stopProjectScriptInternal(projectId?: number): Promise<{ success: } } -export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices): void { +const DAEMON_PROJECT_CHANNELS = [ + 'projects:get-all', + 'projects:get-active', + 'projects:create', + 'projects:activate', + 'projects:update', + 'projects:delete', + 'projects:reorder', + 'projects:detect-branch', + 'projects:list-branches', + 'projects:refresh-git-status', + 'projects:get-running-script', + 'projects:stop-script', + 'projects:detect-config', + 'projects:resolve-run-script', + 'projects:run-script', +] as const; + +export function registerProjectHandlers( + ipcMain: IpcMain, + services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { const { databaseService, sessionManager, worktreeManager, analyticsManager } = services; - ipcMain.handle('projects:get-all', async () => { + commandRegistry.register('projects:get-all', async () => { try { const projects = databaseService.getAllProjects(); const projectsWithEnv = projects.map(p => ({ @@ -67,7 +90,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:get-active', async () => { + commandRegistry.register('projects:get-active', async () => { try { const activeProject = sessionManager.getActiveProject(); const projectWithEnv = activeProject ? { @@ -81,7 +104,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:create', async (_event, projectData: CreateProjectRequest) => { + commandRegistry.register('projects:create', async (projectData: CreateProjectRequest) => { try { console.log('[Main] Creating project:', projectData); @@ -231,7 +254,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:activate', async (_event, projectId: string) => { + commandRegistry.register('projects:activate', async (projectId: string) => { try { const project = databaseService.setActiveProject(parseInt(projectId)); if (project) { @@ -258,7 +281,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:update', async (_event, projectId: string, updates: UpdateProjectRequest) => { + commandRegistry.register('projects:update', async (projectId: string, updates: UpdateProjectRequest) => { try { const projectIdNum = parseInt(projectId); @@ -319,7 +342,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:delete', async (_event, projectId: string) => { + commandRegistry.register('projects:delete', async (projectId: string) => { try { const projectIdNum = parseInt(projectId); @@ -413,7 +436,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:reorder', async (_event, projectOrders: Array<{ id: number; displayOrder: number }>) => { + commandRegistry.register('projects:reorder', async (projectOrders: Array<{ id: number; displayOrder: number }>) => { try { databaseService.reorderProjects(projectOrders); return { success: true }; @@ -423,7 +446,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:detect-branch', async (_event, path: string) => { + commandRegistry.register('projects:detect-branch', async (path: string) => { try { const wslInfo = parseWSLPath(path); const tempProject = { @@ -440,7 +463,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:list-branches', async (_event, projectId: string) => { + commandRegistry.register('projects:list-branches', async (projectId: string) => { try { const project = databaseService.getProject(parseInt(projectId)); if (!project) { @@ -460,7 +483,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:refresh-git-status', async (_event, projectId: string) => { + commandRegistry.register('projects:refresh-git-status', async (projectId: string) => { try { const projectIdNum = parseInt(projectId); const { gitStatusManager } = services; @@ -510,7 +533,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:get-running-script', async () => { + commandRegistry.register('projects:get-running-script', async () => { try { const runningProjectId = scriptExecutionTracker.getRunningScriptId('project'); return { success: true, data: runningProjectId }; @@ -520,7 +543,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:stop-script', async (_event, projectId?: number) => { + commandRegistry.register('projects:stop-script', async (projectId?: number) => { return stopProjectScriptInternal(projectId); }); @@ -547,7 +570,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) * - `main/src/services/projectConfigDetector.ts` — `detectProjectConfig` implementation * - `frontend/src/components/ProjectSettings.tsx` — consumer (badge rendering) */ - ipcMain.handle('projects:detect-config', async (_event, projectId: string) => { + commandRegistry.register('projects:detect-config', async (projectId: string) => { try { const project = databaseService.getProject(parseInt(projectId)); if (!project) return { success: false, error: 'Project not found' }; @@ -578,7 +601,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) * Steps 2-5 are handled by `detectProjectConfig()`. * DB values always override config files (Conductor model). */ - ipcMain.handle('projects:resolve-run-script', async (_event, sessionId: string) => { + commandRegistry.register('projects:resolve-run-script', async (sessionId: string) => { try { const dbSession = databaseService.getSession(sessionId); if (!dbSession?.project_id) return { success: true, data: null }; @@ -629,7 +652,7 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('projects:run-script', async (_event, projectId: number) => { + commandRegistry.register('projects:run-script', async (projectId: number) => { try { // Get the project const project = databaseService.getProject(projectId); @@ -723,4 +746,5 @@ export function registerProjectHandlers(ipcMain: IpcMain, services: AppServices) return { success: false, error: error instanceof Error ? error.message : 'Failed to run project script' }; } }); + commandRegistry.bindChannels(ipcMain, DAEMON_PROJECT_CHANNELS); } diff --git a/main/src/ipc/prompt.ts b/main/src/ipc/prompt.ts index 9d92c537..13dd3705 100644 --- a/main/src/ipc/prompt.ts +++ b/main/src/ipc/prompt.ts @@ -1,8 +1,19 @@ -import { IpcMain } from 'electron'; +import type { IpcMain } from 'electron'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { AppServices } from './types'; -export function registerPromptHandlers(ipcMain: IpcMain, { sessionManager }: AppServices): void { - ipcMain.handle('sessions:get-prompts', async (_event, sessionId: string) => { +const DAEMON_PROMPT_CHANNELS = [ + 'sessions:get-prompts', + 'prompts:get-all', + 'prompts:get-by-id', +] as const; + +export function registerPromptHandlers( + ipcMain: IpcMain, + { sessionManager }: AppServices, + commandRegistry: PaneCommandRegistry, +): void { + commandRegistry.register('sessions:get-prompts', async (sessionId: string) => { try { const prompts = sessionManager.getSessionPrompts(sessionId); return { success: true, data: prompts }; @@ -13,7 +24,7 @@ export function registerPromptHandlers(ipcMain: IpcMain, { sessionManager }: App }); // Prompts handlers - ipcMain.handle('prompts:get-all', async () => { + commandRegistry.register('prompts:get-all', async () => { try { const prompts = sessionManager.getPromptHistory(); return { success: true, data: prompts }; @@ -23,7 +34,7 @@ export function registerPromptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('prompts:get-by-id', async (_event, promptId: string) => { + commandRegistry.register('prompts:get-by-id', async (promptId: string) => { try { const promptMarker = sessionManager.getPromptById(promptId); return { success: true, data: promptMarker }; @@ -32,4 +43,6 @@ export function registerPromptHandlers(ipcMain: IpcMain, { sessionManager }: App return { success: false, error: 'Failed to get prompt by id' }; } }); -} \ No newline at end of file + + commandRegistry.bindChannels(ipcMain, DAEMON_PROMPT_CHANNELS); +} From 01a2ffa8518bde1591701888e5e11de311f7f5c6 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:22:08 -0700 Subject: [PATCH 010/111] feat: route terminal runtime IPC through registry --- main/src/ipc/daemonRegistryBindings.test.ts | 96 +++++++++++++++++++++ main/src/ipc/index.ts | 4 +- main/src/ipc/panels.ts | 96 ++++++++++++++------- main/src/ipc/script.ts | 52 +++++++---- shared/types/daemon.ts | 2 + 5 files changed, 202 insertions(+), 48 deletions(-) diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 7d39b37e..2bb7583b 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -1,14 +1,36 @@ import { describe, expect, it, vi } from 'vitest'; import { PaneCommandRegistry } from '../daemon/commandRegistry'; import { registerFileHandlers } from './file'; +import { registerPanelHandlers } from './panels'; import { registerProjectHandlers } from './project'; import { registerPromptHandlers } from './prompt'; +import { registerScriptHandlers } from './script'; import type { AppServices } from './types'; +vi.mock('../index', () => ({ + webviewContextMap: new Map(), +})); + vi.mock('../services/panelManager', () => ({ panelManager: {}, })); +vi.mock('../services/terminalPanelManager', () => ({ + terminalPanelManager: {}, +})); + +vi.mock('../services/database', () => ({ + databaseService: {}, +})); + +vi.mock('../services/panels/logPanel/logsManager', () => ({ + logsManager: {}, +})); + +vi.mock('../services/scriptExecutionTracker', () => ({ + scriptExecutionTracker: {}, +})); + const PROJECT_CHANNELS = [ 'projects:get-all', 'projects:get-active', @@ -57,6 +79,49 @@ const FILE_CHANNELS = [ 'file:resolveAbsolutePath', ] as const; +const PANEL_CHANNELS = [ + 'panels:create', + 'panels:delete', + 'panels:update', + 'panels:list', + 'panels:set-active', + 'panels:getActive', + 'panels:initialize', + 'panels:checkInitialized', + 'panels:emitEvent', + 'panels:resize-terminal', + 'panels:send-terminal-input', + 'panels:shouldAutoCreate', + 'terminal:input', + 'terminal:resize', + 'terminal:getState', + 'terminal:saveState', + 'terminal:saveSnapshot', + 'terminal:clearScrollback', + 'terminal:setVisibility', + 'terminal:ack', + 'terminal:resetFlowControl', + 'terminal:getAltScreenState', + 'terminal:getScrollbackClean', + 'terminal:paste-image', + 'terminal:save-scrollback', + 'terminal:paste-file', +] as const; + +const SCRIPT_CHANNELS = [ + 'sessions:has-run-script', + 'sessions:get-running-session', + 'sessions:run-script', + 'sessions:stop-script', + 'sessions:run-terminal-command', + 'sessions:send-terminal-input', + 'sessions:pre-create-terminal', + 'sessions:resize-terminal', + 'logs:runScript', + 'logs:stopScript', + 'logs:isRunning', +] as const; + interface IpcMainStub { boundChannels: string[]; handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; @@ -118,4 +183,35 @@ describe('daemon registry IPC bindings', () => { ); expect(registry.has('file:showInFolder')).toBe(false); }); + + it('keeps browser and clipboard panel adapters outside the daemon registry surface', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerPanelHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...PANEL_CHANNELS].sort()); + expect(ipcMain.boundChannels).toContain('terminal:clipboard-paste-image'); + expect(ipcMain.boundChannels).toContain('browser-panel:register-webview'); + expect( + ipcMain.boundChannels.filter( + channel => channel !== 'terminal:clipboard-paste-image' && channel !== 'browser-panel:register-webview', + ).sort(), + ).toEqual([...PANEL_CHANNELS].sort()); + expect(registry.has('terminal:clipboard-paste-image')).toBe(false); + }); + + it('keeps local IDE launching outside the daemon registry surface', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerScriptHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...SCRIPT_CHANNELS].sort()); + expect(ipcMain.boundChannels).toContain('sessions:open-ide'); + expect(ipcMain.boundChannels.filter(channel => channel !== 'sessions:open-ide').sort()).toEqual( + [...SCRIPT_CHANNELS].sort(), + ); + expect(registry.has('sessions:open-ide')).toBe(false); + }); }); diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index ba25b2ac..035937aa 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -35,14 +35,14 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerConfigHandlers(ipcMain, services); registerDialogHandlers(ipcMain, services); registerGitHandlers(ipcMain, services); - registerScriptHandlers(ipcMain, services); + registerScriptHandlers(ipcMain, services, commandRegistry); registerPromptHandlers(ipcMain, services, commandRegistry); registerFileHandlers(ipcMain, services, commandRegistry); registerFolderHandlers(ipcMain, services, commandRegistry); registerUIStateHandlers(services); registerDashboardHandlers(ipcMain, services); setupLogHandlers(ipcMain, services.sessionManager, commandRegistry); - registerPanelHandlers(ipcMain, services); + registerPanelHandlers(ipcMain, services, commandRegistry); registerEditorPanelHandlers(ipcMain, services); registerNimbalystHandlers(ipcMain, services); registerSpotlightHandlers(ipcMain, services); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index 869fadbf..1daa4e0b 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -1,9 +1,11 @@ -import { IpcMain, BrowserWindow, clipboard } from 'electron'; +import { clipboard } from 'electron'; +import type { IpcMain } from 'electron'; import { existsSync, readdirSync } from 'fs'; import fs from 'fs/promises'; import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import { webviewContextMap } from '../index'; import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; @@ -311,9 +313,42 @@ function stripAnsiCodes(text: string): string { return result; } -export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { +const DAEMON_PANEL_CHANNELS = [ + 'panels:create', + 'panels:delete', + 'panels:update', + 'panels:list', + 'panels:set-active', + 'panels:getActive', + 'panels:initialize', + 'panels:checkInitialized', + 'panels:emitEvent', + 'panels:resize-terminal', + 'panels:send-terminal-input', + 'panels:shouldAutoCreate', + 'terminal:input', + 'terminal:resize', + 'terminal:getState', + 'terminal:saveState', + 'terminal:saveSnapshot', + 'terminal:clearScrollback', + 'terminal:setVisibility', + 'terminal:ack', + 'terminal:resetFlowControl', + 'terminal:getAltScreenState', + 'terminal:getScrollbackClean', + 'terminal:paste-image', + 'terminal:save-scrollback', + 'terminal:paste-file', +] as const; + +export function registerPanelHandlers( + ipcMain: IpcMain, + services: AppServices, + commandRegistry: PaneCommandRegistry, +) { // Panel CRUD operations - ipcMain.handle('panels:create', async (_, request: CreatePanelRequest) => { + commandRegistry.register('panels:create', async (request: CreatePanelRequest) => { try { const panel = await panelManager.createPanel(request); return { success: true, data: panel }; @@ -323,7 +358,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:delete', async (_, panelId: string) => { + commandRegistry.register('panels:delete', async (panelId: string) => { try { // Clean up terminal process if it's a terminal panel const panel = panelManager.getPanel(panelId); @@ -339,7 +374,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:update', async (_, panelId: string, updates: Partial) => { + commandRegistry.register('panels:update', async (panelId: string, updates: Partial) => { try { // Track panel rename if title is being updated if (updates.title) { @@ -359,7 +394,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:list', async (_, sessionId: string) => { + commandRegistry.register('panels:list', async (sessionId: string) => { try { const panels = panelManager.getPanelsForSession(sessionId); return { success: true, data: panels }; @@ -369,7 +404,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:set-active', async (_, sessionId: string, panelId: string) => { + commandRegistry.register('panels:set-active', async (sessionId: string, panelId: string) => { try { await panelManager.setActivePanel(sessionId, panelId); return { success: true }; @@ -379,12 +414,12 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:getActive', async (_, sessionId: string) => { + commandRegistry.register('panels:getActive', async (sessionId: string) => { return databaseService.getActivePanel(sessionId); }); // Panel initialization (lazy loading) - ipcMain.handle('panels:initialize', async (_, panelId: string, options?: { cwd?: string; sessionId?: string; cols?: number; rows?: number }) => { + commandRegistry.register('panels:initialize', async (panelId: string, options?: { cwd?: string; sessionId?: string; cols?: number; rows?: number }) => { const panel = panelManager.getPanel(panelId); if (!panel) { @@ -418,7 +453,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { return true; }); - ipcMain.handle('panels:checkInitialized', async (_, panelId: string) => { + commandRegistry.register('panels:checkInitialized', async (panelId: string) => { const panel = panelManager.getPanel(panelId); if (!panel) return false; @@ -444,13 +479,13 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { }); // Event handlers - ipcMain.handle('panels:emitEvent', async (_, panelId: string, eventType: PanelEventType, data: unknown) => { + commandRegistry.register('panels:emitEvent', async (panelId: string, eventType: PanelEventType, data: unknown) => { return panelManager.emitPanelEvent(panelId, eventType, data); }); // Panel-specific terminal handlers (called via panels: namespace from frontend) - ipcMain.handle('panels:resize-terminal', async (_, panelId: string, cols: number, rows: number) => { + commandRegistry.register('panels:resize-terminal', async (panelId: string, cols: number, rows: number) => { try { await terminalPanelManager.resizeTerminal(panelId, cols, rows); return { success: true }; @@ -460,7 +495,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('panels:send-terminal-input', async (_, panelId: string, data: string) => { + commandRegistry.register('panels:send-terminal-input', async (panelId: string, data: string) => { try { await terminalPanelManager.writeToTerminal(panelId, data); return { success: true }; @@ -474,23 +509,23 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { // are implemented in session.ts as they need access to sessionManager methods // Terminal-specific handlers (internal use) - ipcMain.handle('terminal:input', async (_, panelId: string, data: string) => { + commandRegistry.register('terminal:input', async (panelId: string, data: string) => { return terminalPanelManager.writeToTerminal(panelId, data); }); - ipcMain.handle('terminal:resize', async (_, panelId: string, cols: number, rows: number) => { + commandRegistry.register('terminal:resize', async (panelId: string, cols: number, rows: number) => { return terminalPanelManager.resizeTerminal(panelId, cols, rows); }); - ipcMain.handle('terminal:getState', async (_, panelId: string) => { + commandRegistry.register('terminal:getState', async (panelId: string) => { return terminalPanelManager.getTerminalState(panelId); }); - ipcMain.handle('terminal:saveState', async (_, panelId: string) => { + commandRegistry.register('terminal:saveState', async (panelId: string) => { return terminalPanelManager.saveTerminalState(panelId); }); - ipcMain.handle('terminal:saveSnapshot', async (_event, panelId: string, serializedData: string) => { + commandRegistry.register('terminal:saveSnapshot', async (panelId: string, serializedData: string) => { try { terminalPanelManager.saveSerializedSnapshot(panelId, serializedData); return { success: true }; @@ -500,7 +535,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { } }); - ipcMain.handle('terminal:clearScrollback', async (_event, panelId: string) => { + commandRegistry.register('terminal:clearScrollback', async (panelId: string) => { try { await terminalPanelManager.clearTerminalScrollback(panelId); return { success: true }; @@ -513,25 +548,25 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { // Renderer tells main when a terminal panel becomes (in)visible so PTY // output cadence can drop to OUTPUT_BATCH_INTERVAL_HIDDEN while hidden. // No-op when the panel's PTY isn't in the map (pre-init / post-destroy). - ipcMain.handle('terminal:setVisibility', async (_event, panelId: string, isVisible: boolean) => { + commandRegistry.register('terminal:setVisibility', async (panelId: string, isVisible: boolean) => { terminalPanelManager.setVisibility(panelId, !!isVisible); }); - ipcMain.handle('terminal:ack', async (_, panelId: string, bytesConsumed: number) => { + commandRegistry.register('terminal:ack', async (panelId: string, bytesConsumed: number) => { terminalPanelManager.acknowledgeBytes(panelId, bytesConsumed); }); // Reset flow control state (for recovering from stuck terminals) - ipcMain.handle('terminal:resetFlowControl', async (_, panelId: string) => { + commandRegistry.register('terminal:resetFlowControl', async (panelId: string) => { terminalPanelManager.resetFlowControl(panelId); }); // Get alternate screen state for TUI detection on panel mount - ipcMain.handle('terminal:getAltScreenState', async (_, panelId: string) => { + commandRegistry.register('terminal:getAltScreenState', async (panelId: string) => { return terminalPanelManager.getAltScreenState(panelId); }); - ipcMain.handle('terminal:getScrollbackClean', async (_, panelId: string, lines: number) => { + commandRegistry.register('terminal:getScrollbackClean', async (panelId: string, lines: number) => { try { // Try live in-memory scrollback first (active terminals) let rawScrollback = terminalPanelManager.getTerminalScrollback(panelId); @@ -572,8 +607,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { // Save a pasted image to the appropriate .pane/images/ and return the file path with image number. // For WSL-enabled sessions, the file is written to the WSL distro's ~/.pane/images/ so // Claude CLI (running inside WSL) can read it at a native Linux path instead of /mnt/c/... - ipcMain.handle('terminal:paste-image', async ( - _, + commandRegistry.register('terminal:paste-image', async ( _panelId: string, sessionId: string, dataUrl: string, @@ -623,6 +657,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { // Fallback clipboard image check for platforms where browser clipboardData // doesn't contain image data (WSL, some Linux configs). // Reads system clipboard using platform-specific tools. + // This remains adapter-side because the source clipboard belongs to the local client. ipcMain.handle('terminal:clipboard-paste-image', async (_, sessionId: string) => { try { return await readClipboardImageFallback(sessionId); @@ -636,8 +671,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { }); // Save terminal scrollback to ~/.pane/files/ as a .txt and return the resolved path - ipcMain.handle('terminal:save-scrollback', async ( - _, + commandRegistry.register('terminal:save-scrollback', async ( panelId: string, sessionId: string, lines: number, @@ -686,8 +720,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { }); // Save a dropped file (any type) to .pane/files/ and return the resolved path - ipcMain.handle('terminal:paste-file', async ( - _, + commandRegistry.register('terminal:paste-file', async ( sessionId: string, dataUrl: string, originalFileName: string @@ -715,7 +748,7 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { }); // Check if a panel type should be auto-created (not previously closed by user) - ipcMain.handle('panels:shouldAutoCreate', async (_, sessionId: string, panelType: string) => { + commandRegistry.register('panels:shouldAutoCreate', async (sessionId: string, panelType: string) => { return panelManager.shouldAutoCreatePanel(sessionId, panelType); }); @@ -726,4 +759,5 @@ export function registerPanelHandlers(ipcMain: IpcMain, services: AppServices) { return { success: true }; }); + commandRegistry.bindChannels(ipcMain, DAEMON_PANEL_CHANNELS); } diff --git a/main/src/ipc/script.ts b/main/src/ipc/script.ts index 804132cf..96f0cf9e 100644 --- a/main/src/ipc/script.ts +++ b/main/src/ipc/script.ts @@ -1,14 +1,33 @@ -import { IpcMain } from 'electron'; +import type { IpcMain } from 'electron'; import type { AppServices } from './types'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import { getShellPath, findExecutableInPath } from '../utils/shellPath'; import { logsManager } from '../services/panels/logPanel/logsManager'; import { panelManager } from '../services/panelManager'; -import { ExecException } from 'child_process'; +import type { ExecException } from 'child_process'; import { scriptExecutionTracker } from '../services/scriptExecutionTracker'; -export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: AppServices): void { +const DAEMON_SCRIPT_CHANNELS = [ + 'sessions:has-run-script', + 'sessions:get-running-session', + 'sessions:run-script', + 'sessions:stop-script', + 'sessions:run-terminal-command', + 'sessions:send-terminal-input', + 'sessions:pre-create-terminal', + 'sessions:resize-terminal', + 'logs:runScript', + 'logs:stopScript', + 'logs:isRunning', +] as const; + +export function registerScriptHandlers( + ipcMain: IpcMain, + { sessionManager }: AppServices, + commandRegistry: PaneCommandRegistry, +): void { // Script execution handlers - ipcMain.handle('sessions:has-run-script', async (_event, sessionId: string) => { + commandRegistry.register('sessions:has-run-script', async (sessionId: string) => { try { const runScript = sessionManager.getProjectRunScript(sessionId); return { success: true, data: !!runScript }; @@ -18,7 +37,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:get-running-session', async () => { + commandRegistry.register('sessions:get-running-session', async () => { try { const runningSessionId = sessionManager.getCurrentRunningSessionId(); return { success: true, data: runningSessionId }; @@ -28,7 +47,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:run-script', async (_event, sessionId: string) => { + commandRegistry.register('sessions:run-script', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -101,7 +120,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:stop-script', async (_event, sessionId?: string) => { + commandRegistry.register('sessions:stop-script', async (sessionId?: string) => { try { // If sessionId provided, stop that session's logs panel // Otherwise stop the old running script (for backward compatibility) @@ -149,7 +168,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:run-terminal-command', async (_event, sessionId: string, command: string) => { + commandRegistry.register('sessions:run-terminal-command', async (sessionId: string, command: string) => { try { await sessionManager.runTerminalCommand(sessionId, command); return { success: true }; @@ -165,7 +184,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:send-terminal-input', async (_event, sessionId: string, data: string) => { + commandRegistry.register('sessions:send-terminal-input', async (sessionId: string, data: string) => { try { await sessionManager.sendTerminalInput(sessionId, data); return { success: true }; @@ -181,7 +200,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:pre-create-terminal', async (_event, sessionId: string) => { + commandRegistry.register('sessions:pre-create-terminal', async (sessionId: string) => { try { await sessionManager.preCreateTerminalSession(sessionId); return { success: true }; @@ -191,7 +210,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('sessions:resize-terminal', async (_event, sessionId: string, cols: number, rows: number) => { + commandRegistry.register('sessions:resize-terminal', async (sessionId: string, cols: number, rows: number) => { try { sessionManager.resizeTerminal(sessionId, cols, rows); return { success: true }; @@ -207,6 +226,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App cursor: 'cursor .', }; + // Opening a local IDE is a client-local adapter action, not daemon-owned runtime behavior. ipcMain.handle('sessions:open-ide', async (_event, sessionId: string, ideKey?: unknown) => { try { const session = await sessionManager.getSession(sessionId); @@ -288,7 +308,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App }); // Logs panel specific handlers - ipcMain.handle('logs:runScript', async (_event, sessionId: string, command: string, cwd: string) => { + commandRegistry.register('logs:runScript', async (sessionId: string, command: string, cwd: string) => { try { const ctx = sessionManager.getProjectContext(sessionId); await logsManager.runScript(sessionId, command, cwd, ctx?.commandRunner.wslContext || null); @@ -299,7 +319,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('logs:stopScript', async (_event, panelId: string) => { + commandRegistry.register('logs:stopScript', async (panelId: string) => { try { await logsManager.stopScript(panelId); return { success: true }; @@ -309,7 +329,7 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App } }); - ipcMain.handle('logs:isRunning', async (_event, sessionId: string) => { + commandRegistry.register('logs:isRunning', async (sessionId: string) => { try { const isRunning = await logsManager.isRunning(sessionId); return { success: true, data: isRunning }; @@ -318,4 +338,6 @@ export function registerScriptHandlers(ipcMain: IpcMain, { sessionManager }: App return { success: false, error: error instanceof Error ? error.message : 'Failed to check script status' }; } }); -} \ No newline at end of file + + commandRegistry.bindChannels(ipcMain, DAEMON_SCRIPT_CHANNELS); +} diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index 48f7e5b3..2b90c923 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -87,6 +87,8 @@ const DAEMON_OWNED_EXACT_CHANNELS = [ const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ 'file:showInFolder', + 'sessions:open-ide', + 'terminal:clipboard-paste-image', ]); export function isDaemonOwnedChannel(channel: string): boolean { From 0661906ee006eaf7c777f9bb2bfb6ea927b48e59 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:26:57 -0700 Subject: [PATCH 011/111] feat: route session runtime IPC through registry --- main/src/ipc/daemonRegistryBindings.test.ts | 66 ++++++++++ main/src/ipc/index.ts | 2 +- main/src/ipc/session.ts | 131 ++++++++++++++------ shared/types/daemon.ts | 1 + 4 files changed, 158 insertions(+), 42 deletions(-) diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 2bb7583b..30ebef41 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -5,6 +5,7 @@ import { registerPanelHandlers } from './panels'; import { registerProjectHandlers } from './project'; import { registerPromptHandlers } from './prompt'; import { registerScriptHandlers } from './script'; +import { registerSessionHandlers } from './session'; import type { AppServices } from './types'; vi.mock('../index', () => ({ @@ -122,6 +123,43 @@ const SCRIPT_CHANNELS = [ 'logs:isRunning', ] as const; +const SESSION_CHANNELS = [ + 'sessions:get-all', + 'sessions:get', + 'sessions:get-all-with-projects', + 'sessions:get-archived-with-projects', + 'sessions:create', + 'sessions:delete', + 'sessions:input', + 'sessions:get-or-create-main-repo', + 'sessions:continue', + 'sessions:get-output', + 'sessions:get-conversation', + 'sessions:get-conversation-messages', + 'sessions:get-conversation-message-count', + 'sessions:generate-compacted-context', + 'sessions:get-json-messages', + 'sessions:mark-viewed', + 'sessions:stop', + 'sessions:generate-name', + 'sessions:rename', + 'sessions:toggle-favorite', + 'sessions:reorder', + 'sessions:save-images', + 'sessions:save-large-text', + 'sessions:restore', + 'sessions:get-statistics', + 'sessions:get-resumable', + 'sessions:resume-interrupted', + 'sessions:dismiss-interrupted', + 'panels:get-output', + 'panels:get-conversation-messages', + 'panels:get-json-messages', + 'panels:get-prompts', + 'panels:send-input', + 'panels:continue', +] as const; + interface IpcMainStub { boundChannels: string[]; handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; @@ -146,6 +184,13 @@ function createServicesStub(): AppServices { databaseService: {}, worktreeManager: {}, analyticsManager: {}, + taskQueue: {}, + cliManagerFactory: {}, + claudeCodeManager: {}, + worktreeNameGenerator: {}, + archiveProgressManager: {}, + spotlightManager: {}, + runCommandManager: {}, } as AppServices; } @@ -214,4 +259,25 @@ describe('daemon registry IPC bindings', () => { ); expect(registry.has('sessions:open-ide')).toBe(false); }); + + it('keeps active-session polling hints outside the daemon registry surface', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerSessionHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...SESSION_CHANNELS].sort()); + expect(ipcMain.boundChannels).toContain('sessions:set-active-session'); + expect(ipcMain.boundChannels).toContain('debug:get-table-structure'); + expect(ipcMain.boundChannels).toContain('archive:get-progress'); + expect( + ipcMain.boundChannels.filter( + channel => + channel !== 'sessions:set-active-session' && + channel !== 'debug:get-table-structure' && + channel !== 'archive:get-progress', + ).sort(), + ).toEqual([...SESSION_CHANNELS].sort()); + expect(registry.has('sessions:set-active-session')).toBe(false); + }); }); diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 035937aa..d8a511a2 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -30,7 +30,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerAppHandlers(ipcMain, services); registerUpdaterHandlers(ipcMain, services); - registerSessionHandlers(ipcMain, services); + registerSessionHandlers(ipcMain, services, commandRegistry); registerProjectHandlers(ipcMain, services, commandRegistry); registerConfigHandlers(ipcMain, services); registerDialogHandlers(ipcMain, services); diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index a6be5f53..8d9e09b5 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -4,11 +4,12 @@ * "sessions" in code, database, and IPC to avoid a massive refactor. */ -import { IpcMain } from 'electron'; +import type { IpcMain } from 'electron'; import * as path from 'path'; import * as fs from 'fs/promises'; import { existsSync } from 'fs'; import type { AppServices } from './types'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import type { CreateSessionRequest } from '../types/session'; import { getAppSubdirectory } from '../utils/appDirectory'; import { convertDbFolderToRendererFolder } from '../services/folderEvents'; @@ -17,16 +18,60 @@ import { panelManager } from '../services/panelManager'; import { terminalPanelManager } from '../services/terminalPanelManager'; import { validateSessionExists, - validatePanelSessionOwnership, + validatePanelSessionOwnership, validatePanelExists, validateSessionIsActive, logValidationFailure, - createValidationError + createValidationError, } from '../utils/sessionValidation'; import type { SerializedArchiveTask } from '../services/archiveProgressManager'; import { detectProjectConfig } from '../services/projectConfigDetector'; -export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices): void { +const DAEMON_SESSION_CHANNELS = [ + 'sessions:get-all', + 'sessions:get', + 'sessions:get-all-with-projects', + 'sessions:get-archived-with-projects', + 'sessions:create', + 'sessions:delete', + 'sessions:input', + 'sessions:get-or-create-main-repo', + 'sessions:continue', + 'sessions:get-output', + 'sessions:get-conversation', + 'sessions:get-conversation-messages', + 'sessions:get-conversation-message-count', + 'sessions:generate-compacted-context', + 'sessions:get-json-messages', + 'sessions:mark-viewed', + 'sessions:stop', + 'sessions:generate-name', + 'sessions:rename', + 'sessions:toggle-favorite', + 'sessions:reorder', + 'sessions:save-images', + 'sessions:save-large-text', + 'sessions:restore', + 'sessions:get-statistics', + 'sessions:get-resumable', + 'sessions:resume-interrupted', + 'sessions:dismiss-interrupted', +] as const; + +const DAEMON_SESSION_PANEL_CHANNELS = [ + 'panels:get-output', + 'panels:get-conversation-messages', + 'panels:get-json-messages', + 'panels:get-prompts', + 'panels:send-input', + 'panels:continue', +] as const; + +export function registerSessionHandlers( + ipcMain: IpcMain, + services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { const { sessionManager, databaseService, @@ -59,7 +104,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) // Future versions will use getCliManager() to support multiple CLI tools dynamically // Session management handlers - ipcMain.handle('sessions:get-all', async () => { + commandRegistry.register('sessions:get-all', async () => { try { const sessions = await sessionManager.getAllSessions(); return { success: true, data: sessions }; @@ -69,7 +114,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); @@ -83,7 +128,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-all-with-projects', async () => { + commandRegistry.register('sessions:get-all-with-projects', async () => { try { const allProjects = databaseService.getAllProjects(); const projectsWithSessions = allProjects.map(project => { @@ -103,7 +148,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-archived-with-projects', async () => { + commandRegistry.register('sessions:get-archived-with-projects', async () => { try { const allProjects = databaseService.getAllProjects(); const projectsWithArchivedSessions = allProjects.map(project => { @@ -121,7 +166,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:create', async (_event, request: CreateSessionRequest) => { + commandRegistry.register('sessions:create', async (request: CreateSessionRequest) => { try { let targetProject; @@ -217,7 +262,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:delete', async (_event, sessionId: string) => { + commandRegistry.register('sessions:delete', async (sessionId: string) => { try { // Get database session details before archiving (includes worktree_name and project_id) const dbSession = databaseService.getSession(sessionId); @@ -480,7 +525,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:input', async (_event, sessionId: string, input: string) => { + commandRegistry.register('sessions:input', async (sessionId: string, input: string) => { try { // Validate session exists and is active const sessionValidation = validateSessionIsActive(sessionId); @@ -548,7 +593,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-or-create-main-repo', async (_event, projectId: number) => { + commandRegistry.register('sessions:get-or-create-main-repo', async (projectId: number) => { try { console.log('[IPC] sessions:get-or-create-main-repo handler called with projectId:', projectId); @@ -574,7 +619,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:continue', async (_event, sessionId: string, prompt?: string, model?: string) => { + commandRegistry.register('sessions:continue', async (sessionId: string, prompt?: string, model?: string) => { try { // Validate session exists and is active const sessionValidation = validateSessionIsActive(sessionId); @@ -713,7 +758,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-output', async (_event, sessionId: string, limit?: number) => { + commandRegistry.register('sessions:get-output', async (sessionId: string, limit?: number) => { try { // Validate session exists const sessionValidation = validateSessionExists(sessionId); @@ -781,7 +826,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-conversation', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-conversation', async (sessionId: string) => { try { // Always use session-based conversation retrieval const messages = await sessionManager.getConversationMessages(sessionId); @@ -792,7 +837,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-conversation-messages', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-conversation-messages', async (sessionId: string) => { try { // Always use session-based conversation retrieval const messages = await sessionManager.getConversationMessages(sessionId); @@ -803,9 +848,9 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle( + commandRegistry.register( 'sessions:get-conversation-message-count', - async (_event, sessionId: string) => { + async (sessionId: string) => { try { const count = sessionManager.getConversationMessageCount(sessionId); return { success: true, data: count }; @@ -819,7 +864,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) ); // Panel-based handlers for Claude panels - ipcMain.handle('panels:get-output', async (_event, panelId: string, limit?: number) => { + commandRegistry.register('panels:get-output', async (panelId: string, limit?: number) => { try { // Validate panel exists const panelValidation = validatePanelExists(panelId); @@ -845,7 +890,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('panels:get-conversation-messages', async (_event, panelId: string) => { + commandRegistry.register('panels:get-conversation-messages', async (panelId: string) => { try { if (!sessionManager.getPanelConversationMessages) { console.error('[IPC] Panel-based conversation methods not available on sessionManager'); @@ -867,7 +912,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('panels:get-json-messages', async (_event, panelId: string) => { + commandRegistry.register('panels:get-json-messages', async (panelId: string) => { try { console.log(`[IPC] panels:get-json-messages called for panel: ${panelId}`); @@ -923,7 +968,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('panels:get-prompts', async (_event, panelId: string) => { + commandRegistry.register('panels:get-prompts', async (panelId: string) => { try { console.log(`[IPC] panels:get-prompts called for panel: ${panelId}`); @@ -963,7 +1008,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); // Generic panel input handlers that route to specific panel type handlers - ipcMain.handle('panels:send-input', async (_event, panelId: string, input: string) => { + commandRegistry.register('panels:send-input', async (panelId: string, input: string) => { try { console.log(`[IPC] panels:send-input called for panel: ${panelId}`); @@ -1003,7 +1048,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('panels:continue', async (_event, panelId: string, input: string, model?: string) => { + commandRegistry.register('panels:continue', async (panelId: string, input: string, model?: string) => { try { console.log(`[IPC] panels:continue called for panel: ${panelId}`); @@ -1037,7 +1082,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:generate-compacted-context', async (_event, sessionId: string) => { + commandRegistry.register('sessions:generate-compacted-context', async (sessionId: string) => { try { console.log('[IPC] sessions:generate-compacted-context called for sessionId:', sessionId); @@ -1107,7 +1152,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:get-json-messages', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-json-messages', async (sessionId: string) => { try { console.log(`[IPC] sessions:get-json-messages called for session: ${sessionId}`); @@ -1177,7 +1222,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:mark-viewed', async (_event, sessionId: string) => { + commandRegistry.register('sessions:mark-viewed', async (sessionId: string) => { try { await sessionManager.markSessionAsViewed(sessionId); return { success: true }; @@ -1187,7 +1232,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:stop', async (_event, sessionId: string) => { + commandRegistry.register('sessions:stop', async (sessionId: string) => { try { // Use session-based stop console.log(`[IPC] Stopping session ${sessionId} via session-based method`); @@ -1222,7 +1267,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:generate-name', async (_event, prompt: string) => { + commandRegistry.register('sessions:generate-name', async (prompt: string) => { try { const name = await worktreeNameGenerator.generateWorktreeName(prompt); return { success: true, data: name }; @@ -1232,7 +1277,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:rename', async (_event, sessionId: string, newName: string) => { + commandRegistry.register('sessions:rename', async (sessionId: string, newName: string) => { try { // Update the session name in the database const updatedSession = databaseService.updateSession(sessionId, { name: newName }); @@ -1254,7 +1299,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:toggle-favorite', async (_event, sessionId: string) => { + commandRegistry.register('sessions:toggle-favorite', async (sessionId: string) => { try { console.log('[IPC] sessions:toggle-favorite called for sessionId:', sessionId); @@ -1299,7 +1344,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:reorder', async (_event, sessionOrders: Array<{ id: string; displayOrder: number }>) => { + commandRegistry.register('sessions:reorder', async (sessionOrders: Array<{ id: string; displayOrder: number }>) => { try { databaseService.reorderSessions(sessionOrders); return { success: true }; @@ -1310,7 +1355,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); // Save images for a session - ipcMain.handle('sessions:save-images', async (_event, sessionId: string, images: Array<{ name: string; dataUrl: string; type: string }>) => { + commandRegistry.register('sessions:save-images', async (sessionId: string, images: Array<{ name: string; dataUrl: string; type: string }>) => { try { // For pending sessions (those created before the actual session), we still need to save the files // Check if this is a pending session ID (starts with 'pending_') @@ -1359,7 +1404,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); // Save large text for a session - ipcMain.handle('sessions:save-large-text', async (_event, sessionId: string, text: string) => { + commandRegistry.register('sessions:save-large-text', async (sessionId: string, text: string) => { try { // For pending sessions (those created before the actual session), we still need to save the files // Check if this is a pending session ID (starts with 'pending_') @@ -1398,7 +1443,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:restore', async (_event, sessionId: string) => { + commandRegistry.register('sessions:restore', async (sessionId: string) => { try { const restored = databaseService.restoreSession(sessionId); if (!restored) { @@ -1452,7 +1497,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); // Session statistics handler - ipcMain.handle('sessions:get-statistics', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-statistics', async (sessionId: string) => { try { console.log('[IPC] sessions:get-statistics called for sessionId:', sessionId); @@ -1554,8 +1599,9 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - // Set active session for smart git status polling - ipcMain.handle('sessions:set-active-session', async (event, sessionId: string | null) => { + // Set active session for smart git status polling. + // This is a client-local visibility hint, not shared runtime state. + ipcMain.handle('sessions:set-active-session', async (_event, sessionId: string | null) => { try { // Notify GitStatusManager about the active session change gitStatusManager.setActiveSession(sessionId); @@ -1567,7 +1613,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) }); // Resume session handlers - ipcMain.handle('sessions:get-resumable', async () => { + commandRegistry.register('sessions:get-resumable', async () => { try { const activeProject = sessionManager.getActiveProject(); if (!activeProject) { @@ -1581,7 +1627,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:resume-interrupted', async (_event, sessionIds: string[]) => { + commandRegistry.register('sessions:resume-interrupted', async (sessionIds: string[]) => { try { await sessionManager.resumeInterruptedSessions(sessionIds); return { success: true }; @@ -1591,7 +1637,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); - ipcMain.handle('sessions:dismiss-interrupted', async (_event, sessionIds: string[]) => { + commandRegistry.register('sessions:dismiss-interrupted', async (sessionIds: string[]) => { try { await sessionManager.dismissInterruptedSessions(sessionIds); return { success: true }; @@ -1601,4 +1647,7 @@ export function registerSessionHandlers(ipcMain: IpcMain, services: AppServices) } }); + commandRegistry.bindChannels(ipcMain, DAEMON_SESSION_CHANNELS); + commandRegistry.bindChannels(ipcMain, DAEMON_SESSION_PANEL_CHANNELS); + } diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index 2b90c923..fb6e7cc9 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -88,6 +88,7 @@ const DAEMON_OWNED_EXACT_CHANNELS = [ const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ 'file:showInFolder', 'sessions:open-ide', + 'sessions:set-active-session', 'terminal:clipboard-paste-image', ]); From 4ca943297c0fe70c94371a639ad427377fae8afa Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:31:21 -0700 Subject: [PATCH 012/111] feat: route git status IPC through registry --- main/src/ipc/daemonRegistryBindings.test.ts | 55 +++++++++++++++++ main/src/ipc/git.ts | 65 +++++++++++++++------ main/src/ipc/index.ts | 2 +- 3 files changed, 102 insertions(+), 20 deletions(-) diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 30ebef41..deae2006 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { PaneCommandRegistry } from '../daemon/commandRegistry'; import { registerFileHandlers } from './file'; +import { registerGitHandlers } from './git'; import { registerPanelHandlers } from './panels'; import { registerProjectHandlers } from './project'; import { registerPromptHandlers } from './prompt'; @@ -160,6 +161,43 @@ const SESSION_CHANNELS = [ 'panels:continue', ] as const; +const GIT_STATUS_CHANNELS = [ + 'sessions:get-executions', + 'sessions:get-execution-diff', + 'sessions:get-git-graph', + 'git:file-status', + 'sessions:git-diff', + 'sessions:get-commit-diff-by-hash', + 'sessions:get-combined-diff', + 'sessions:check-rebase-conflicts', + 'sessions:has-stash', + 'sessions:get-upstream', + 'sessions:get-remote-branches', + 'sessions:get-last-commits', + 'sessions:has-changes-to-rebase', + 'sessions:get-git-commands', + 'sessions:get-git-status', + 'git:cancel-status-for-project', + 'git:get-github-remote', +] as const; + +const GIT_DIRECT_MUTATION_CHANNELS = [ + 'sessions:git-commit', + 'sessions:rebase-main-into-worktree', + 'sessions:abort-rebase-and-use-claude', + 'sessions:squash-and-rebase-to-main', + 'sessions:rebase-to-main', + 'sessions:git-pull', + 'sessions:git-push', + 'sessions:git-soft-reset', + 'sessions:git-fetch', + 'sessions:git-stash', + 'sessions:git-stash-pop', + 'sessions:set-upstream', + 'sessions:git-stage-and-commit', + 'git:clone-repo', +] as const; + interface IpcMainStub { boundChannels: string[]; handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; @@ -183,6 +221,7 @@ function createServicesStub(): AppServices { configManager: {}, databaseService: {}, worktreeManager: {}, + gitDiffManager: {}, analyticsManager: {}, taskQueue: {}, cliManagerFactory: {}, @@ -280,4 +319,20 @@ describe('daemon registry IPC bindings', () => { ).toEqual([...SESSION_CHANNELS].sort()); expect(registry.has('sessions:set-active-session')).toBe(false); }); + + it('keeps git mutators direct while routing git status and history through the registry', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerGitHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...GIT_STATUS_CHANNELS].sort()); + expect( + ipcMain.boundChannels.filter(channel => !GIT_DIRECT_MUTATION_CHANNELS.includes(channel as (typeof GIT_DIRECT_MUTATION_CHANNELS)[number])).sort(), + ).toEqual([...GIT_STATUS_CHANNELS].sort()); + expect(ipcMain.boundChannels.filter(channel => GIT_DIRECT_MUTATION_CHANNELS.includes(channel as (typeof GIT_DIRECT_MUTATION_CHANNELS)[number])).sort()).toEqual( + [...GIT_DIRECT_MUTATION_CHANNELS].sort(), + ); + expect(registry.has('git:clone-repo')).toBe(false); + }); }); diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index d023d62c..481f2b54 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -1,7 +1,8 @@ -import { IpcMain } from 'electron'; +import type { IpcMain } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; import type { AppServices } from './types'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; import { buildGitCommitCommand } from '../utils/shellEscape'; import { getPaneEventSink } from '../core/runtime'; import { panelEventBus } from '../services/panelEventBus'; @@ -64,7 +65,31 @@ function extractRepoName(url: string): string { return lastSegment; } -export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): void { +const DAEMON_GIT_STATUS_CHANNELS = [ + 'sessions:get-executions', + 'sessions:get-execution-diff', + 'sessions:get-git-graph', + 'git:file-status', + 'sessions:git-diff', + 'sessions:get-commit-diff-by-hash', + 'sessions:get-combined-diff', + 'sessions:check-rebase-conflicts', + 'sessions:has-stash', + 'sessions:get-upstream', + 'sessions:get-remote-branches', + 'sessions:get-last-commits', + 'sessions:has-changes-to-rebase', + 'sessions:get-git-commands', + 'sessions:get-git-status', + 'git:cancel-status-for-project', + 'git:get-github-remote', +] as const; + +export function registerGitHandlers( + ipcMain: IpcMain, + services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { const { sessionManager, gitDiffManager, worktreeManager, claudeCodeManager, gitStatusManager, databaseService } = services; // Helper function to emit git operation events to all sessions in a project @@ -201,7 +226,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo }; }; - ipcMain.handle('sessions:get-executions', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-executions', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -262,7 +287,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-execution-diff', async (_event, sessionId: string, executionId: string) => { + commandRegistry.register('sessions:get-execution-diff', async (sessionId: string, executionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -290,7 +315,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-git-graph', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-git-graph', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -427,7 +452,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('git:file-status', async (_event, sessionId: string, filePath: string) => { + commandRegistry.register('git:file-status', async (sessionId: string, filePath: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -455,7 +480,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:git-diff', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-diff', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -482,7 +507,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-commit-diff-by-hash', async (_event, sessionId: string, commitHash: string) => { + commandRegistry.register('sessions:get-commit-diff-by-hash', async (sessionId: string, commitHash: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -510,7 +535,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-combined-diff', async (_event, sessionId: string, executionIds?: number[]) => { + commandRegistry.register('sessions:get-combined-diff', async (sessionId: string, executionIds?: number[]) => { try { // Get session to find worktree path const session = await sessionManager.getSession(sessionId); @@ -777,7 +802,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo }); // Git rebase operations - ipcMain.handle('sessions:check-rebase-conflicts', async (_event, sessionId: string) => { + commandRegistry.register('sessions:check-rebase-conflicts', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1634,7 +1659,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:has-stash', async (_event, sessionId: string) => { + commandRegistry.register('sessions:has-stash', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1693,7 +1718,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-upstream', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-upstream', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1718,7 +1743,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-remote-branches', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-remote-branches', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1804,7 +1829,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-last-commits', async (_event, sessionId: string, count: number = 50) => { + commandRegistry.register('sessions:get-last-commits', async (sessionId: string, count: number = 50) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1848,7 +1873,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo }); // Git operation helpers - ipcMain.handle('sessions:has-changes-to-rebase', async (_event, sessionId: string) => { + commandRegistry.register('sessions:has-changes-to-rebase', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -1873,7 +1898,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-git-commands', async (_event, sessionId: string) => { + commandRegistry.register('sessions:get-git-commands', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -1930,7 +1955,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('sessions:get-git-status', async (_event, sessionId: string, nonBlocking?: boolean, isInitialLoad?: boolean) => { + commandRegistry.register('sessions:get-git-status', async (sessionId: string, nonBlocking?: boolean, isInitialLoad?: boolean) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -1979,7 +2004,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('git:cancel-status-for-project', async (_event, projectId: number) => { + commandRegistry.register('git:cancel-status-for-project', async (projectId: number) => { try { // Get all sessions for the project const sessions = await sessionManager.getAllSessions(); @@ -1996,7 +2021,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo } }); - ipcMain.handle('git:get-github-remote', async (_event, sessionId: string) => { + commandRegistry.register('git:get-github-remote', async (sessionId: string) => { try { const session = sessionManager.getSession(sessionId); if (!session?.worktreePath) { @@ -2095,4 +2120,6 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo return { success: false, error: errorMsg }; } }); + + commandRegistry.bindChannels(ipcMain, DAEMON_GIT_STATUS_CHANNELS); } diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index d8a511a2..6868f037 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -34,7 +34,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerProjectHandlers(ipcMain, services, commandRegistry); registerConfigHandlers(ipcMain, services); registerDialogHandlers(ipcMain, services); - registerGitHandlers(ipcMain, services); + registerGitHandlers(ipcMain, services, commandRegistry); registerScriptHandlers(ipcMain, services, commandRegistry); registerPromptHandlers(ipcMain, services, commandRegistry); registerFileHandlers(ipcMain, services, commandRegistry); From 777dd059b720a918c0665e7eeeb377384760867d Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:34:14 -0700 Subject: [PATCH 013/111] feat: route git mutator IPC through registry --- main/src/ipc/daemonRegistryBindings.test.ts | 20 ++++---- main/src/ipc/git.ts | 52 +++++++++++++++------ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index deae2006..02302853 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -181,7 +181,7 @@ const GIT_STATUS_CHANNELS = [ 'git:get-github-remote', ] as const; -const GIT_DIRECT_MUTATION_CHANNELS = [ +const GIT_MUTATION_CHANNELS = [ 'sessions:git-commit', 'sessions:rebase-main-into-worktree', 'sessions:abort-rebase-and-use-claude', @@ -198,6 +198,11 @@ const GIT_DIRECT_MUTATION_CHANNELS = [ 'git:clone-repo', ] as const; +const GIT_CHANNELS = [ + ...GIT_STATUS_CHANNELS, + ...GIT_MUTATION_CHANNELS, +] as const; + interface IpcMainStub { boundChannels: string[]; handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => unknown): void; @@ -320,19 +325,14 @@ describe('daemon registry IPC bindings', () => { expect(registry.has('sessions:set-active-session')).toBe(false); }); - it('keeps git mutators direct while routing git status and history through the registry', () => { + it('routes all daemon-owned git handlers through the shared registry', () => { const registry = new PaneCommandRegistry(); const ipcMain = createIpcMainStub(); registerGitHandlers(ipcMain, createServicesStub(), registry); - expect(registry.listChannels()).toEqual([...GIT_STATUS_CHANNELS].sort()); - expect( - ipcMain.boundChannels.filter(channel => !GIT_DIRECT_MUTATION_CHANNELS.includes(channel as (typeof GIT_DIRECT_MUTATION_CHANNELS)[number])).sort(), - ).toEqual([...GIT_STATUS_CHANNELS].sort()); - expect(ipcMain.boundChannels.filter(channel => GIT_DIRECT_MUTATION_CHANNELS.includes(channel as (typeof GIT_DIRECT_MUTATION_CHANNELS)[number])).sort()).toEqual( - [...GIT_DIRECT_MUTATION_CHANNELS].sort(), - ); - expect(registry.has('git:clone-repo')).toBe(false); + expect(registry.listChannels()).toEqual([...GIT_CHANNELS].sort()); + expect(ipcMain.boundChannels.sort()).toEqual([...GIT_CHANNELS].sort()); + expect(registry.has('git:clone-repo')).toBe(true); }); }); diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 481f2b54..815d8f5a 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -85,6 +85,28 @@ const DAEMON_GIT_STATUS_CHANNELS = [ 'git:get-github-remote', ] as const; +const DAEMON_GIT_MUTATION_CHANNELS = [ + 'sessions:git-commit', + 'sessions:rebase-main-into-worktree', + 'sessions:abort-rebase-and-use-claude', + 'sessions:squash-and-rebase-to-main', + 'sessions:rebase-to-main', + 'sessions:git-pull', + 'sessions:git-push', + 'sessions:git-soft-reset', + 'sessions:git-fetch', + 'sessions:git-stash', + 'sessions:git-stash-pop', + 'sessions:set-upstream', + 'sessions:git-stage-and-commit', + 'git:clone-repo', +] as const; + +const DAEMON_GIT_CHANNELS = [ + ...DAEMON_GIT_STATUS_CHANNELS, + ...DAEMON_GIT_MUTATION_CHANNELS, +] as const; + export function registerGitHandlers( ipcMain: IpcMain, services: AppServices, @@ -408,7 +430,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-commit', async (_event, sessionId: string, message: string) => { + commandRegistry.register('sessions:git-commit', async (sessionId: string, message: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session || !session.worktreePath) { @@ -840,7 +862,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:rebase-main-into-worktree', async (_event, sessionId: string) => { + commandRegistry.register('sessions:rebase-main-into-worktree', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -986,7 +1008,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:abort-rebase-and-use-claude', async (_event, sessionId: string) => { + commandRegistry.register('sessions:abort-rebase-and-use-claude', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1071,7 +1093,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:squash-and-rebase-to-main', async (_event, sessionId: string, commitMessage: string) => { + commandRegistry.register('sessions:squash-and-rebase-to-main', async (sessionId: string, commitMessage: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1166,7 +1188,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:rebase-to-main', async (_event, sessionId: string) => { + commandRegistry.register('sessions:rebase-to-main', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1253,7 +1275,7 @@ export function registerGitHandlers( }); // Git pull/push operations for main repo sessions - ipcMain.handle('sessions:git-pull', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-pull', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1332,7 +1354,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-push', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-push', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1409,7 +1431,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-soft-reset', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-soft-reset', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1479,7 +1501,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-fetch', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-fetch', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1539,7 +1561,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-stash', async (_event, sessionId: string, message?: string) => { + commandRegistry.register('sessions:git-stash', async (sessionId: string, message?: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1599,7 +1621,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-stash-pop', async (_event, sessionId: string) => { + commandRegistry.register('sessions:git-stash-pop', async (sessionId: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1684,7 +1706,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:set-upstream', async (_event, sessionId: string, remoteBranch: string) => { + commandRegistry.register('sessions:set-upstream', async (sessionId: string, remoteBranch: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -1768,7 +1790,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('sessions:git-stage-and-commit', async (_event, sessionId: string, message: string) => { + commandRegistry.register('sessions:git-stage-and-commit', async (sessionId: string, message: string) => { try { const session = await sessionManager.getSession(sessionId); if (!session) { @@ -2060,7 +2082,7 @@ export function registerGitHandlers( } }); - ipcMain.handle('git:clone-repo', async (_event, url: string, destDir: string) => { + commandRegistry.register('git:clone-repo', async (url: string, destDir: string) => { if (!isValidGitUrl(url)) { return { success: false, error: 'Invalid repository URL. Use https:// or git@ format.' }; } @@ -2121,5 +2143,5 @@ export function registerGitHandlers( } }); - commandRegistry.bindChannels(ipcMain, DAEMON_GIT_STATUS_CHANNELS); + commandRegistry.bindChannels(ipcMain, DAEMON_GIT_CHANNELS); } From 0f59af76d9ecfd9a7346b8485a13c707912a0196 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:40:42 -0700 Subject: [PATCH 014/111] feat: start local Pane daemon server --- main/src/core/importBoundary.test.ts | 9 + main/src/daemon/server.test.ts | 173 +++++++++++++++++++ main/src/daemon/server.ts | 238 +++++++++++++++++++++++++++ main/src/index.ts | 42 ++++- 4 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 main/src/daemon/server.test.ts create mode 100644 main/src/daemon/server.ts diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 942b8701..48813878 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -44,4 +44,13 @@ describe('daemon/client import boundary', () => { expect(source, relativePath).not.toContain('mainWindow'); } }); + + it('keeps the daemon server free of Electron bootstrap imports', () => { + const source = readMainSrcFile('daemon/server.ts'); + + expect(source).not.toContain("from 'electron'"); + expect(source).not.toContain('mainWindow'); + expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); + expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + }); }); diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts new file mode 100644 index 00000000..679f5ab6 --- /dev/null +++ b/main/src/daemon/server.test.ts @@ -0,0 +1,173 @@ +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PaneDaemonFrame } from '../../../shared/types/daemon'; +import { PaneCommandRegistry } from './commandRegistry'; +import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; +import { PaneDaemonServer } from './server'; + +interface TestClient { + socket: net.Socket; + nextFrame(timeoutMs?: number): Promise; +} + +const activeServers: PaneDaemonServer[] = []; +const activeSockets: net.Socket[] = []; +const activeTempDirs: string[] = []; + +afterEach(async () => { + for (const socket of activeSockets.splice(0)) { + if (!socket.destroyed) { + socket.destroy(); + } + } + + for (const server of activeServers.splice(0)) { + await server.stop(); + } + + for (const tempDir of activeTempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +function createTempAppDirectory(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-daemon-server-')); + activeTempDirs.push(tempDir); + return tempDir; +} + +async function connectClient(server: PaneDaemonServer): Promise { + const endpoint = server.getEndpoint(); + const socket = await new Promise((resolve, reject) => { + const client = net.createConnection(endpoint.path, () => resolve(client)); + client.once('error', reject); + }); + + activeSockets.push(socket); + + const decoder = new PaneDaemonFrameDecoder(); + const queuedFrames: PaneDaemonFrame[] = []; + const waiters: Array<(frame: PaneDaemonFrame) => void> = []; + + socket.on('data', (chunk) => { + const frames = decoder.push(chunk); + for (const frame of frames) { + const waiter = waiters.shift(); + if (waiter) { + waiter(frame); + } else { + queuedFrames.push(frame); + } + } + }); + + return { + socket, + nextFrame(timeoutMs = 1000) { + if (queuedFrames.length > 0) { + return Promise.resolve(queuedFrames.shift() as PaneDaemonFrame); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for Pane daemon frame')); + }, timeoutMs); + + waiters.push((frame) => { + clearTimeout(timeout); + resolve(frame); + }); + }); + }, + }; +} + +describe('PaneDaemonServer', () => { + it('serves registered daemon commands over the local endpoint', async () => { + const registry = new PaneCommandRegistry(); + registry.register('sessions:get-all', async () => [{ id: 'session-1' }]); + + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 1, + channel: 'sessions:get-all', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 1, + ok: true, + result: [{ id: 'session-1' }], + }); + }); + + it('returns structured errors for unknown daemon channels', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 2, + channel: 'sessions:missing', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 2, + ok: false, + error: { + message: 'No Pane daemon command registered for channel "sessions:missing"', + code: 'ERR_UNKNOWN_CHANNEL', + }, + }); + }); + + it('broadcasts daemon-owned events and filters Electron-only events', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('session:created', { id: 'session-1' }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'session:created', + args: [{ id: 'session-1' }], + }); + + server.getEventSink().send('version:update-available', { version: '1.2.3' }); + await expect(client.nextFrame(100)).rejects.toThrow('Timed out waiting for Pane daemon frame'); + }); + + it('cleans up the Unix socket file when stopped', async () => { + if (process.platform === 'win32') { + return; + } + + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); + await server.start(); + + const socketPath = server.getEndpoint().path; + expect(fs.existsSync(socketPath)).toBe(true); + + await server.stop(); + + expect(fs.existsSync(socketPath)).toBe(false); + }); +}); diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts new file mode 100644 index 00000000..36024c1a --- /dev/null +++ b/main/src/daemon/server.ts @@ -0,0 +1,238 @@ +import fs from 'fs'; +import net from 'net'; +import path from 'path'; +import type { PaneEventSink } from '../core/eventSink'; +import type { PaneCommandRegistry } from './commandRegistry'; +import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; +import { getPaneDaemonEndpoint, type PaneDaemonEndpoint } from './socketPath'; +import type { + PaneDaemonErrorResponseFrame, + PaneDaemonRequestFrame, + PaneDaemonSuccessResponseFrame, +} from '../../../shared/types/daemon'; + +const DAEMON_EVENT_PREFIXES = [ + 'archive:', + 'folder:', + 'panel:', + 'project:', + 'resource-monitor:', + 'session:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_EVENT_EXACT_CHANNELS = new Set([ + 'git-status-loading', + 'git-status-updated', + 'session-log', + 'session-logs-cleared', +]); + +function isPaneDaemonEventChannel(channel: string): boolean { + if (DAEMON_EVENT_EXACT_CHANNELS.has(channel)) { + return true; + } + + return DAEMON_EVENT_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + +interface ConnectedPaneDaemonClient { + socket: net.Socket; + decoder: PaneDaemonFrameDecoder; +} + +export class PaneDaemonServer { + private server: net.Server | null = null; + private readonly clients = new Map(); + private readonly endpoint: PaneDaemonEndpoint; + private nextClientId = 1; + + private readonly daemonEventSink: PaneEventSink = { + send: (channel: string, ...args: unknown[]) => { + if (!isPaneDaemonEventChannel(channel) || this.clients.size === 0) { + return; + } + + const encodedFrame = encodePaneDaemonFrame({ + type: 'event', + channel, + args, + }); + + for (const [clientId, client] of this.clients) { + try { + client.socket.write(encodedFrame); + } catch { + this.dropClient(clientId); + } + } + }, + }; + + constructor( + private readonly commandRegistry: PaneCommandRegistry, + appDirectory: string, + platform: NodeJS.Platform = process.platform, + ) { + this.endpoint = getPaneDaemonEndpoint(appDirectory, platform); + } + + getEndpoint(): PaneDaemonEndpoint { + return this.endpoint; + } + + getEventSink(): PaneEventSink { + return this.daemonEventSink; + } + + hasSubscribers(): boolean { + return this.clients.size > 0; + } + + async start(): Promise { + if (this.server) { + throw new Error('Pane daemon server is already running'); + } + + if (this.endpoint.transport === 'unix') { + fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); + if (fs.existsSync(this.endpoint.path)) { + fs.unlinkSync(this.endpoint.path); + } + } + + const server = net.createServer((socket) => { + this.attachClient(socket); + }); + + await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.removeListener('listening', handleListening); + this.server = null; + reject(error); + }; + + const handleListening = () => { + server.removeListener('error', handleError); + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(this.endpoint.path); + }); + + server.on('error', (error) => { + console.error('[Pane daemon] Server error:', error); + }); + this.server = server; + } + + async stop(): Promise { + const server = this.server; + this.server = null; + + for (const clientId of [...this.clients.keys()]) { + this.dropClient(clientId); + } + + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + + if (this.endpoint.transport === 'unix' && fs.existsSync(this.endpoint.path)) { + fs.unlinkSync(this.endpoint.path); + } + } + + private attachClient(socket: net.Socket): void { + const clientId = String(this.nextClientId++); + const client: ConnectedPaneDaemonClient = { + socket, + decoder: new PaneDaemonFrameDecoder(), + }; + + this.clients.set(clientId, client); + + socket.on('data', (chunk) => { + try { + const frames = client.decoder.push(chunk); + for (const frame of frames) { + if (frame.type !== 'request') { + socket.destroy(new Error(`Pane daemon clients must send request frames, received "${frame.type}"`)); + return; + } + + void this.handleRequest(socket, frame); + } + } catch (error) { + socket.destroy(error instanceof Error ? error : new Error(String(error))); + } + }); + + socket.on('error', () => { + this.dropClient(clientId); + }); + + socket.on('close', () => { + this.clients.delete(clientId); + try { + client.decoder.finish(); + } catch { + // The client closed mid-frame. Treat it as a disconnected subscriber. + } + }); + } + + private dropClient(clientId: string): void { + const client = this.clients.get(clientId); + if (!client) { + return; + } + + this.clients.delete(clientId); + if (!client.socket.destroyed) { + client.socket.destroy(); + } + } + + private async handleRequest(socket: net.Socket, frame: PaneDaemonRequestFrame): Promise { + const response = await this.buildResponseFrame(frame); + + if (!socket.destroyed) { + socket.write(encodePaneDaemonFrame(response)); + } + } + + private async buildResponseFrame( + frame: PaneDaemonRequestFrame, + ): Promise { + try { + const result = await this.commandRegistry.invoke(frame.channel, frame.args); + return { + type: 'response', + id: frame.id, + ok: true, + result, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = message.includes('No Pane daemon command registered') + ? 'ERR_UNKNOWN_CHANNEL' + : 'ERR_DAEMON_REQUEST_FAILED'; + + return { + type: 'response', + id: frame.id, + ok: false, + error: { + message, + code, + }, + }; + } + } +} diff --git a/main/src/index.ts b/main/src/index.ts index 6792e853..ac59914a 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -45,7 +45,7 @@ import { getCurrentWorktreeName } from './utils/worktreeUtils'; import { registerIpcHandlers } from './ipc'; import { setupAutoUpdater } from './autoUpdater'; import { setupEventListeners } from './events'; -import type { PaneEventSink } from './core/eventSink'; +import { createFanoutEventSink, type PaneEventSink } from './core/eventSink'; import { setPaneRuntime } from './core/runtime'; import { AppServices } from './ipc/types'; import { getCloudVmManager } from './ipc/cloud'; @@ -58,6 +58,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'; export let mainWindow: BrowserWindow | null = null; @@ -76,6 +77,15 @@ const electronPaneEventSink: PaneEventSink = { }, }; +function installPaneRuntime(eventSink: PaneEventSink): void { + setPaneRuntime({ + eventSink, + getConfigManager: () => configManager, + getPtyHostRuntime: () => ptyHostSupervisor, + getWebviewContextMap: () => webviewContextMap, + }); +} + // Active DevTools WebContentsViews, keyed by the page webContentsId they inspect const activeDevToolsViews = new Map(); let devToolsHandlersRegistered = false; @@ -177,6 +187,7 @@ 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; @@ -965,12 +976,7 @@ async function createWindow() { async function initializeServices() { configManager = new ConfigManager(); await configManager.initialize(); - setPaneRuntime({ - eventSink: electronPaneEventSink, - getConfigManager: () => configManager, - getPtyHostRuntime: () => ptyHostSupervisor, - getWebviewContextMap: () => webviewContextMap, - }); + installPaneRuntime(electronPaneEventSink); // Initialize logger early so it can capture all logs logger = new Logger(configManager); @@ -1126,7 +1132,20 @@ async function initializeServices() { }; // Initialize IPC handlers first so managers (like ClaudePanelManager) are ready - registerIpcHandlers(services); + const commandRegistry = registerIpcHandlers(services); + + try { + paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory()); + await paneDaemonServer.start(); + installPaneRuntime(createFanoutEventSink([ + electronPaneEventSink, + 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); @@ -1560,6 +1579,13 @@ app.on('before-quit', async (event) => { 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(); From fc288966734d4a584782d4bf7ade19cf3c474e6e Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:27:21 -0700 Subject: [PATCH 015/111] fix: harden local daemon socket startup --- main/src/daemon/server.test.ts | 89 ++++++++++++++++++++++++++++++++++ main/src/daemon/server.ts | 31 ++++++++++++ 2 files changed, 120 insertions(+) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 679f5ab6..2fd30890 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -154,6 +154,44 @@ describe('PaneDaemonServer', () => { await expect(client.nextFrame(100)).rejects.toThrow('Timed out waiting for Pane daemon frame'); }); + it('forwards logs panel runtime events to daemon clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('logs:output', { + panelId: 'panel-1', + content: 'ready\n', + type: 'stdout', + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'logs:output', + args: [{ + panelId: 'panel-1', + content: 'ready\n', + type: 'stdout', + }], + }); + + server.getEventSink().send('process:ended', { + panelId: 'panel-1', + exitCode: 0, + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'process:ended', + args: [{ + panelId: 'panel-1', + exitCode: 0, + }], + }); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; @@ -170,4 +208,55 @@ describe('PaneDaemonServer', () => { expect(fs.existsSync(socketPath)).toBe(false); }); + + it('rejects replacing an active Unix socket listener at the same path', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = createTempAppDirectory(); + const firstServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(firstServer); + await firstServer.start(); + + const secondServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + await expect(secondServer.start()).rejects.toThrow( + `Pane daemon server is already listening on ${firstServer.getEndpoint().path}`, + ); + + const client = await connectClient(firstServer); + client.socket.write(encodePaneDaemonFrame({ + type: 'request', + id: 9, + channel: 'sessions:get-all', + args: [], + })); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'response', + id: 9, + ok: false, + error: { + message: 'No Pane daemon command registered for channel "sessions:get-all"', + code: 'ERR_UNKNOWN_CHANNEL', + }, + }); + }); + + it('replaces stale non-socket files at the Unix socket path', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = createTempAppDirectory(); + const probeServer = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + const socketPath = probeServer.getEndpoint().path; + fs.mkdirSync(path.dirname(socketPath), { recursive: true }); + fs.writeFileSync(socketPath, 'stale'); + + const server = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(server); + await expect(server.start()).resolves.toBeUndefined(); + expect(fs.existsSync(socketPath)).toBe(true); + }); }); diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index 36024c1a..fd78c934 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -25,6 +25,8 @@ const DAEMON_EVENT_PREFIXES = [ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'git-status-loading', 'git-status-updated', + 'logs:output', + 'process:ended', 'session-log', 'session-logs-cleared', ]); @@ -98,6 +100,11 @@ export class PaneDaemonServer { if (this.endpoint.transport === 'unix') { fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); if (fs.existsSync(this.endpoint.path)) { + const unixSocketStatus = await probeUnixSocketPath(this.endpoint.path); + if (unixSocketStatus === 'active') { + throw new Error(`Pane daemon server is already listening on ${this.endpoint.path}`); + } + fs.unlinkSync(this.endpoint.path); } } @@ -236,3 +243,27 @@ export class PaneDaemonServer { } } } + +async function probeUnixSocketPath(socketPath: string): Promise<'active' | 'stale'> { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + + const settle = (result: 'active' | 'stale') => { + socket.removeAllListeners(); + if (!socket.destroyed) { + socket.destroy(); + } + resolve(result); + }; + + socket.once('connect', () => settle('active')); + socket.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT' || error.code === 'ENOTSOCK') { + settle('stale'); + return; + } + + reject(error); + }); + }); +} From 0253759ad0c2018a31d33a9c39e79cfee260c45d Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:33:12 -0700 Subject: [PATCH 016/111] fix: forward script state daemon events --- main/src/daemon/server.test.ts | 37 ++++++++++++++++++++++++++++++++++ main/src/daemon/server.ts | 4 ++++ 2 files changed, 41 insertions(+) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 2fd30890..ef009858 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -192,6 +192,43 @@ describe('PaneDaemonServer', () => { }); }); + it('forwards script state events to daemon clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('script-closing', 'session-1'); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'script-closing', + args: ['session-1'], + }); + + server.getEventSink().send('project-script-closing', { projectId: 12 }); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'project-script-closing', + args: [{ projectId: 12 }], + }); + + server.getEventSink().send('script-session-changed', 'session-2'); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'script-session-changed', + args: ['session-2'], + }); + + server.getEventSink().send('project-script-changed', { projectId: null }); + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'project-script-changed', + args: [{ projectId: null }], + }); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index fd78c934..e7b7cc88 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -27,8 +27,12 @@ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'git-status-updated', 'logs:output', 'process:ended', + 'project-script-changed', + 'project-script-closing', 'session-log', 'session-logs-cleared', + 'script-closing', + 'script-session-changed', ]); function isPaneDaemonEventChannel(channel: string): boolean { From 9fee906823c81ae3039c838e1816d11b217224de Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:39:25 -0700 Subject: [PATCH 017/111] fix: drop backpressured daemon clients --- main/src/daemon/server.test.ts | 45 +++++++++++++++++++++++++++++++++- main/src/daemon/server.ts | 25 ++++++++++++++----- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index ef009858..72e62599 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { PaneDaemonFrame } from '../../../shared/types/daemon'; import { PaneCommandRegistry } from './commandRegistry'; import { encodePaneDaemonFrame, PaneDaemonFrameDecoder } from './socketFraming'; @@ -229,6 +229,49 @@ describe('PaneDaemonServer', () => { }); }); + it('drops backpressured daemon event subscribers while continuing to deliver to healthy clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + await connectClient(server); + const healthyClient = await connectClient(server); + const stalledServerSocket = (server as unknown as { clients: Map }).clients.get('1')?.socket; + expect(stalledServerSocket).toBeDefined(); + const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(() => false); + + server.getEventSink().send('terminal:output', { + panelId: 'panel-1', + data: 'hello\n', + }); + + await expect(healthyClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'hello\n', + }], + }); + + server.getEventSink().send('terminal:output', { + panelId: 'panel-1', + data: 'world\n', + }); + + await expect(healthyClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'world\n', + }], + }); + expect(stalledWriteSpy).toHaveBeenCalledTimes(1); + expect(server.hasSubscribers()).toBe(true); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index e7b7cc88..6e2ec5e6 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -67,11 +67,7 @@ export class PaneDaemonServer { }); for (const [clientId, client] of this.clients) { - try { - client.socket.write(encodedFrame); - } catch { - this.dropClient(clientId); - } + this.writeFrame(clientId, client.socket, encodedFrame); } }, }; @@ -214,7 +210,24 @@ export class PaneDaemonServer { const response = await this.buildResponseFrame(frame); if (!socket.destroyed) { - socket.write(encodePaneDaemonFrame(response)); + const clientId = [...this.clients.entries()].find(([, client]) => client.socket === socket)?.[0]; + const encodedFrame = encodePaneDaemonFrame(response); + if (clientId) { + this.writeFrame(clientId, socket, encodedFrame); + } else { + socket.write(encodedFrame); + } + } + } + + private writeFrame(clientId: string, socket: net.Socket, encodedFrame: string): void { + try { + const accepted = socket.write(encodedFrame); + if (!accepted) { + this.dropClient(clientId); + } + } catch { + this.dropClient(clientId); } } From e182d6cce11c0545f1076136c5bcffe4fe534d99 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:49:07 -0700 Subject: [PATCH 018/111] fix: harden local daemon socket server --- main/src/daemon/server.test.ts | 58 +++++++++++++++++-- main/src/daemon/server.ts | 101 +++++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 72e62599..1f44db8f 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -229,23 +229,45 @@ describe('PaneDaemonServer', () => { }); }); - it('drops backpressured daemon event subscribers while continuing to deliver to healthy clients', async () => { + it('keeps backpressured daemon event subscribers connected until queued frames drain', async () => { + if (process.platform === 'win32') { + return; + } + const registry = new PaneCommandRegistry(); - const server = new PaneDaemonServer(registry, createTempAppDirectory()); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); activeServers.push(server); await server.start(); - await connectClient(server); + const stalledClient = await connectClient(server); const healthyClient = await connectClient(server); const stalledServerSocket = (server as unknown as { clients: Map }).clients.get('1')?.socket; expect(stalledServerSocket).toBeDefined(); - const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(() => false); + const originalWrite = (stalledServerSocket as net.Socket).write.bind(stalledServerSocket); + let shouldBackpressure = true; + const stalledWriteSpy = vi.spyOn(stalledServerSocket as net.Socket, 'write').mockImplementation(((...args: Parameters) => { + const result = originalWrite(...args); + if (shouldBackpressure) { + shouldBackpressure = false; + return false; + } + + return result; + }) as typeof net.Socket.prototype.write); server.getEventSink().send('terminal:output', { panelId: 'panel-1', data: 'hello\n', }); + await expect(stalledClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'hello\n', + }], + }); await expect(healthyClient.nextFrame()).resolves.toEqual({ type: 'event', channel: 'terminal:output', @@ -268,10 +290,38 @@ describe('PaneDaemonServer', () => { data: 'world\n', }], }); + expect(stalledWriteSpy).toHaveBeenCalledTimes(1); + stalledServerSocket?.emit('drain'); + await expect(stalledClient.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'terminal:output', + args: [{ + panelId: 'panel-1', + data: 'world\n', + }], + }); + + expect(stalledWriteSpy).toHaveBeenCalledTimes(2); expect(server.hasSubscribers()).toBe(true); }); + it('creates the Unix socket directory and socket file with user-only permissions', async () => { + if (process.platform === 'win32') { + return; + } + + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory(), 'linux'); + activeServers.push(server); + await server.start(); + + const socketPath = server.getEndpoint().path; + const socketDirectory = path.dirname(socketPath); + expect(fs.statSync(socketDirectory).mode & 0o777).toBe(0o700); + expect(fs.statSync(socketPath).mode & 0o777).toBe(0o600); + }); + it('cleans up the Unix socket file when stopped', async () => { if (process.platform === 'win32') { return; diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index 6e2ec5e6..096d987d 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -46,8 +46,15 @@ function isPaneDaemonEventChannel(channel: string): boolean { interface ConnectedPaneDaemonClient { socket: net.Socket; decoder: PaneDaemonFrameDecoder; + pendingFrames: string[]; + pendingBytes: number; + waitingForDrain: boolean; } +const UNIX_SOCKET_DIRECTORY_MODE = 0o700; +const UNIX_SOCKET_FILE_MODE = 0o600; +const MAX_PENDING_BYTES_PER_CLIENT = 4 * 1024 * 1024; + export class PaneDaemonServer { private server: net.Server | null = null; private readonly clients = new Map(); @@ -66,8 +73,8 @@ export class PaneDaemonServer { args, }); - for (const [clientId, client] of this.clients) { - this.writeFrame(clientId, client.socket, encodedFrame); + for (const [clientId] of this.clients) { + this.writeFrame(clientId, encodedFrame); } }, }; @@ -98,7 +105,9 @@ export class PaneDaemonServer { } if (this.endpoint.transport === 'unix') { - fs.mkdirSync(path.dirname(this.endpoint.path), { recursive: true }); + const socketDirectory = path.dirname(this.endpoint.path); + fs.mkdirSync(socketDirectory, { recursive: true, mode: UNIX_SOCKET_DIRECTORY_MODE }); + fs.chmodSync(socketDirectory, UNIX_SOCKET_DIRECTORY_MODE); if (fs.existsSync(this.endpoint.path)) { const unixSocketStatus = await probeUnixSocketPath(this.endpoint.path); if (unixSocketStatus === 'active') { @@ -122,6 +131,9 @@ export class PaneDaemonServer { const handleListening = () => { server.removeListener('error', handleError); + if (this.endpoint.transport === 'unix') { + fs.chmodSync(this.endpoint.path, UNIX_SOCKET_FILE_MODE); + } resolve(); }; @@ -160,6 +172,9 @@ export class PaneDaemonServer { const client: ConnectedPaneDaemonClient = { socket, decoder: new PaneDaemonFrameDecoder(), + pendingFrames: [], + pendingBytes: 0, + waitingForDrain: false, }; this.clients.set(clientId, client); @@ -173,13 +188,17 @@ export class PaneDaemonServer { return; } - void this.handleRequest(socket, frame); + void this.handleRequest(clientId, frame); } } catch (error) { socket.destroy(error instanceof Error ? error : new Error(String(error))); } }); + socket.on('drain', () => { + this.flushPendingFrames(clientId); + }); + socket.on('error', () => { this.dropClient(clientId); }); @@ -201,36 +220,86 @@ export class PaneDaemonServer { } this.clients.delete(clientId); + client.pendingFrames.length = 0; + client.pendingBytes = 0; + client.waitingForDrain = false; if (!client.socket.destroyed) { client.socket.destroy(); } } - private async handleRequest(socket: net.Socket, frame: PaneDaemonRequestFrame): Promise { + private async handleRequest(clientId: string, frame: PaneDaemonRequestFrame): Promise { const response = await this.buildResponseFrame(frame); + const client = this.clients.get(clientId); - if (!socket.destroyed) { - const clientId = [...this.clients.entries()].find(([, client]) => client.socket === socket)?.[0]; - const encodedFrame = encodePaneDaemonFrame(response); - if (clientId) { - this.writeFrame(clientId, socket, encodedFrame); - } else { - socket.write(encodedFrame); - } + if (!client || client.socket.destroyed) { + return; } + + this.writeFrame(clientId, encodePaneDaemonFrame(response)); } - private writeFrame(clientId: string, socket: net.Socket, encodedFrame: string): void { + private writeFrame(clientId: string, encodedFrame: string): void { + const client = this.clients.get(clientId); + if (!client || client.socket.destroyed) { + return; + } + + if (client.waitingForDrain || client.pendingFrames.length > 0) { + this.queuePendingFrame(clientId, encodedFrame); + return; + } + try { - const accepted = socket.write(encodedFrame); + const accepted = client.socket.write(encodedFrame); if (!accepted) { - this.dropClient(clientId); + client.waitingForDrain = true; } } catch { this.dropClient(clientId); } } + private queuePendingFrame(clientId: string, encodedFrame: string): void { + const client = this.clients.get(clientId); + if (!client) { + return; + } + + client.pendingFrames.push(encodedFrame); + client.pendingBytes += Buffer.byteLength(encodedFrame); + if (client.pendingBytes > MAX_PENDING_BYTES_PER_CLIENT) { + this.dropClient(clientId); + } + } + + private flushPendingFrames(clientId: string): void { + const client = this.clients.get(clientId); + if (!client || client.socket.destroyed) { + return; + } + + client.waitingForDrain = false; + while (client.pendingFrames.length > 0) { + const nextFrame = client.pendingFrames.shift(); + if (!nextFrame) { + break; + } + + client.pendingBytes -= Buffer.byteLength(nextFrame); + try { + const accepted = client.socket.write(nextFrame); + if (!accepted) { + client.waitingForDrain = true; + return; + } + } catch { + this.dropClient(clientId); + return; + } + } + } + private async buildResponseFrame( frame: PaneDaemonRequestFrame, ): Promise { From d866e621830d25801088e9dfc0cfa933ba80d33c Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 17:08:04 -0700 Subject: [PATCH 019/111] fix: shorten unix daemon socket paths --- main/src/daemon/server.test.ts | 14 ++++++++++++++ main/src/daemon/socketPath.test.ts | 22 +++++++++++++++------- main/src/daemon/socketPath.ts | 17 +++++++++++++++-- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 1f44db8f..32fa1c1a 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -389,4 +389,18 @@ describe('PaneDaemonServer', () => { await expect(server.start()).resolves.toBeUndefined(); expect(fs.existsSync(socketPath)).toBe(true); }); + + it('starts successfully for deeply nested app directories on Unix', async () => { + if (process.platform === 'win32') { + return; + } + + const appDirectory = path.posix.join('/tmp', 'pane-root', 'nested'.repeat(40), '.pane'); + const server = new PaneDaemonServer(new PaneCommandRegistry(), appDirectory, 'linux'); + activeServers.push(server); + + await expect(server.start()).resolves.toBeUndefined(); + expect(Buffer.byteLength(server.getEndpoint().path)).toBeLessThan(100); + expect(fs.existsSync(server.getEndpoint().path)).toBe(true); + }); }); diff --git a/main/src/daemon/socketPath.test.ts b/main/src/daemon/socketPath.test.ts index 6877ca92..82543511 100644 --- a/main/src/daemon/socketPath.test.ts +++ b/main/src/daemon/socketPath.test.ts @@ -2,14 +2,13 @@ import { describe, expect, it } from 'vitest'; import { getPaneDaemonEndpoint, getPaneDaemonSocketDirectory } from './socketPath'; describe('Pane daemon socket path', () => { - it('uses a sockets subdirectory and stable socket file on Unix-like platforms', () => { + it('uses a short hashed temp directory and stable socket file on Unix-like platforms', () => { const endpoint = getPaneDaemonEndpoint('/Users/parsa/.pane', 'darwin'); + const socketDirectory = getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin'); - expect(endpoint).toEqual({ - transport: 'unix', - path: '/Users/parsa/.pane/sockets/daemon.sock', - }); - expect(getPaneDaemonSocketDirectory('/Users/parsa/.pane', 'darwin')).toBe('/Users/parsa/.pane/sockets'); + expect(endpoint.transport).toBe('unix'); + expect(endpoint.path).toBe(`${socketDirectory}/daemon.sock`); + expect(socketDirectory).toMatch(/^\/tmp\/pane-daemon(?:-\d+)?-[0-9a-f]{16}$/); }); it('uses a stable named pipe on Windows', () => { @@ -31,6 +30,15 @@ describe('Pane daemon socket path', () => { const endpoint = getPaneDaemonEndpoint('.pane-test', 'linux'); expect(endpoint.transport).toBe('unix'); - expect(endpoint.path.endsWith('/sockets/daemon.sock')).toBe(true); + expect(endpoint.path.startsWith('/tmp/pane-daemon')).toBe(true); + expect(endpoint.path.endsWith('/daemon.sock')).toBe(true); + }); + + it('keeps Unix socket paths short even for deeply nested app directories', () => { + const deepPath = `/Users/parsa/${'very-nested-directory/'.repeat(20)}.pane`; + const endpoint = getPaneDaemonEndpoint(deepPath, 'linux'); + + expect(endpoint.transport).toBe('unix'); + expect(Buffer.byteLength(endpoint.path)).toBeLessThan(100); }); }); diff --git a/main/src/daemon/socketPath.ts b/main/src/daemon/socketPath.ts index 76a90c00..b39ecf53 100644 --- a/main/src/daemon/socketPath.ts +++ b/main/src/daemon/socketPath.ts @@ -6,7 +6,7 @@ export interface PaneDaemonEndpoint { path: string; } -const DAEMON_SOCKET_DIRECTORY = 'sockets'; +const UNIX_SOCKET_BASE_DIRECTORY = '/tmp'; const DAEMON_SOCKET_FILENAME = 'daemon.sock'; function resolveAppDirectory(appDirectory: string, platform: NodeJS.Platform): string { @@ -26,13 +26,26 @@ function getWindowsPipeName(appDirectory: string): string { return `\\\\.\\pipe\\pane-daemon-${hash}`; } +function getUnixSocketDirectoryName(appDirectory: string): string { + const hash = createHash('sha256') + .update(appDirectory) + .digest('hex') + .slice(0, 16); + const uidSuffix = typeof process.getuid === 'function' ? `-${process.getuid()}` : ''; + + return `pane-daemon${uidSuffix}-${hash}`; +} + export function getPaneDaemonSocketDirectory(appDirectory: string, platform: NodeJS.Platform = process.platform): string | null { const resolvedAppDirectory = resolveAppDirectory(appDirectory, platform); if (platform === 'win32') { return null; } - return path.posix.join(resolvedAppDirectory, DAEMON_SOCKET_DIRECTORY); + return path.posix.join( + UNIX_SOCKET_BASE_DIRECTORY, + getUnixSocketDirectoryName(resolvedAppDirectory), + ); } export function getPaneDaemonEndpoint(appDirectory: string, platform: NodeJS.Platform = process.platform): PaneDaemonEndpoint { From e359c7f9cf1c51aac858995b05f153271cd9d355 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:47:32 -0700 Subject: [PATCH 020/111] feat: deliver hidden terminal output to daemon clients --- main/src/core/runtime.test.ts | 6 + main/src/core/runtime.ts | 5 + main/src/index.ts | 5 +- .../src/services/terminalPanelManager.test.ts | 154 ++++++++++++++++++ main/src/services/terminalPanelManager.ts | 13 +- 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 main/src/services/terminalPanelManager.test.ts diff --git a/main/src/core/runtime.test.ts b/main/src/core/runtime.test.ts index 3c177da6..40a11935 100644 --- a/main/src/core/runtime.test.ts +++ b/main/src/core/runtime.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { ConfigManager } from '../services/configManager'; import { + getPaneDaemonEventSink, getPaneEventSink, getPaneRuntime, getPaneWebviewContextMap, @@ -25,10 +26,14 @@ describe('pane runtime', () => { 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 daemonEventSink = { + send: () => undefined, + }; const runtime: PaneRuntime = { eventSink: { send: () => undefined, }, + daemonEventSink, getConfigManager: () => configManager, getPtyHostRuntime: () => null, getWebviewContextMap: () => webviewContextMap, @@ -38,6 +43,7 @@ describe('pane runtime', () => { expect(getPaneRuntime()).toBe(runtime); expect(getPaneEventSink()).toBe(runtime.eventSink); + expect(getPaneDaemonEventSink()).toBe(daemonEventSink); 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 index 2081e49d..68052130 100644 --- a/main/src/core/runtime.ts +++ b/main/src/core/runtime.ts @@ -46,6 +46,7 @@ export interface PtyHostRuntime { */ export interface PaneRuntime { eventSink: PaneEventSink; + daemonEventSink?: PaneEventSink; getConfigManager(): ConfigManager; getPtyHostRuntime(): PtyHostRuntime | null; getWebviewContextMap(): Map; @@ -69,6 +70,10 @@ export function getPaneEventSink(): PaneEventSink { return paneRuntime?.eventSink ?? noopPaneEventSink; } +export function getPaneDaemonEventSink(): PaneEventSink { + return paneRuntime?.daemonEventSink ?? noopPaneEventSink; +} + export function getRuntimeConfigManager(): ConfigManager { return getPaneRuntime().getConfigManager(); } diff --git a/main/src/index.ts b/main/src/index.ts index ac59914a..f172056c 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -77,9 +77,10 @@ const electronPaneEventSink: PaneEventSink = { }, }; -function installPaneRuntime(eventSink: PaneEventSink): void { +function installPaneRuntime(eventSink: PaneEventSink, daemonEventSink?: PaneEventSink): void { setPaneRuntime({ eventSink, + daemonEventSink, getConfigManager: () => configManager, getPtyHostRuntime: () => ptyHostSupervisor, getWebviewContextMap: () => webviewContextMap, @@ -1140,7 +1141,7 @@ async function initializeServices() { 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); diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts new file mode 100644 index 00000000..a51bf579 --- /dev/null +++ b/main/src/services/terminalPanelManager.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ConfigManager } from './configManager'; +import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; +import { createFlowControlRecord, disposeFlowControlRecord, type FlowControlRecord } from '../ptyHost/flowControl'; + +vi.mock('@lydell/node-pty', () => ({})); + +vi.mock('./panelManager', () => ({ + panelManager: { + emitPanelEvent: vi.fn(), + getPanel: vi.fn(), + updatePanel: vi.fn(), + }, +})); + +vi.mock('../utils/shellPath', () => ({ + getShellPath: () => '', +})); + +vi.mock('../utils/shellDetector', () => ({ + ShellDetector: { + getDefaultShell: () => ({ path: '/bin/bash', name: 'bash', args: [] }), + }, +})); + +vi.mock('../utils/wslUtils', () => ({ + getWSLShellSpawn: vi.fn(), + buildWSLENV: vi.fn(() => ''), +})); + +vi.mock('../utils/attribution', () => ({ + GIT_ATTRIBUTION_ENV: {}, +})); + +import { TerminalPanelManager } from './terminalPanelManager'; + +type TerminalUnderTest = { + pty: { + pause: ReturnType; + resume: ReturnType; + }; + isPtyHost: boolean; + panelId: string; + sessionId: string; + scrollbackBuffer: string; + alternateScreenBuffer: string; + commandHistory: string[]; + currentCommand: string; + lastActivity: Date; + wslContext: null; + flowControl: FlowControlRecord; + outputBuffer: string; + outputFlushTimer: ReturnType | null; + isVisible: boolean; + isAlternateScreen: boolean; + activityStatus: 'active' | 'idle'; + idleTimer: ReturnType | null; + inSyncBlock: boolean; + codexResumeOutputBuffer: string; +}; + +type FlushOutputBufferAccess = { + flushOutputBuffer(terminal: TerminalUnderTest): void; +}; + +function createTerminal(overrides: Partial = {}): TerminalUnderTest { + return { + pty: { + pause: vi.fn(), + resume: vi.fn(), + }, + isPtyHost: false, + panelId: 'panel-1', + sessionId: 'session-1', + scrollbackBuffer: '', + alternateScreenBuffer: '', + commandHistory: [], + currentCommand: '', + lastActivity: new Date(), + wslContext: null, + flowControl: createFlowControlRecord(), + outputBuffer: 'hello from terminal', + outputFlushTimer: null, + isVisible: true, + isAlternateScreen: false, + activityStatus: 'idle', + idleTimer: null, + inSyncBlock: false, + codexResumeOutputBuffer: '', + ...overrides, + }; +} + +function createConfigManagerStub(): ConfigManager { + return { + getUsePtyHost: () => false, + } as ConfigManager; +} + +describe('TerminalPanelManager hidden output delivery', () => { + afterEach(() => { + resetPaneRuntimeForTests(); + }); + + it('keeps visible terminal output on the combined runtime sink', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager(); + const terminal = createTerminal(); + + (manager as unknown as FlushOutputBufferAccess).flushOutputBuffer(terminal); + + expect(combinedSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hello from terminal', + }); + expect(daemonSink.send).not.toHaveBeenCalled(); + disposeFlowControlRecord(terminal.flowControl); + }); + + it('sends hidden terminal output to daemon subscribers without waking the renderer sink', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager(); + const terminal = createTerminal({ isVisible: false }); + + (manager as unknown as FlushOutputBufferAccess).flushOutputBuffer(terminal); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hello from terminal', + }); + disposeFlowControlRecord(terminal.flowControl); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index f969cdcd..50fa364c 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1,6 +1,6 @@ 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 { getPaneDaemonEventSink, getPaneEventSink, getPtyHostRuntime, getRuntimeConfigManager, type PtyHandleLike, type PtyHostRuntime } from '../core/runtime'; import { panelManager } from './panelManager'; import * as os from 'os'; import * as path from 'path'; @@ -280,6 +280,10 @@ export class TerminalPanelManager { getPaneEventSink().send(channel, ...args); } + private sendDaemonEvent(channel: string, ...args: unknown[]): void { + getPaneDaemonEventSink().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. @@ -372,6 +376,13 @@ export class TerminalPanelManager { if (!terminal.isVisible) { // Hidden terminals run headless: keep PTY output in main scrollback, but // avoid waking the renderer/xterm/WebGL for every background token. + // Daemon subscribers still need the live bytes so non-Electron clients + // are not starved by one hidden desktop panel. + this.sendDaemonEvent('terminal:output', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + output: data, + }); return; } From 4df593949db10f3a9bac82bdfefbc6c928976587 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:29:35 -0700 Subject: [PATCH 021/111] fix: preserve hidden terminal output on visibility change --- .../src/services/terminalPanelManager.test.ts | 37 +++++++++++++++++++ main/src/services/terminalPanelManager.ts | 37 +++++++++++++++---- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts index a51bf579..789ae2f5 100644 --- a/main/src/services/terminalPanelManager.test.ts +++ b/main/src/services/terminalPanelManager.test.ts @@ -63,6 +63,11 @@ type FlushOutputBufferAccess = { flushOutputBuffer(terminal: TerminalUnderTest): void; }; +type VisibilityAccess = { + terminals: Map; + setVisibility(panelId: string, isVisible: boolean): void; +}; + function createTerminal(overrides: Partial = {}): TerminalUnderTest { return { pty: { @@ -151,4 +156,36 @@ describe('TerminalPanelManager hidden output delivery', () => { }); disposeFlowControlRecord(terminal.flowControl); }); + + it('flushes pending hidden output to daemon subscribers before making a panel visible', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager() as unknown as VisibilityAccess; + const terminal = createTerminal({ + isVisible: false, + outputBuffer: 'hidden output', + outputFlushTimer: setTimeout(() => undefined, 10_000), + }); + manager.terminals.set(terminal.panelId, terminal); + + manager.setVisibility(terminal.panelId, true); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'hidden output', + }); + expect(terminal.outputBuffer).toBe(''); + expect(terminal.outputFlushTimer).toBeNull(); + disposeFlowControlRecord(terminal.flowControl); + }); }); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 50fa364c..bd4616fa 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -378,11 +378,7 @@ export class TerminalPanelManager { // avoid waking the renderer/xterm/WebGL for every background token. // Daemon subscribers still need the live bytes so non-Electron clients // are not starved by one hidden desktop panel. - this.sendDaemonEvent('terminal:output', { - sessionId: terminal.sessionId, - panelId: terminal.panelId, - output: data, - }); + this.sendHiddenOutputToDaemon(terminal, data); return; } @@ -413,6 +409,29 @@ export class TerminalPanelManager { ); } + private sendHiddenOutputToDaemon(terminal: TerminalProcess, data: string): void { + this.sendDaemonEvent('terminal:output', { + sessionId: terminal.sessionId, + panelId: terminal.panelId, + output: data, + }); + } + + private flushPendingHiddenOutputToDaemon(terminal: TerminalProcess): void { + if (terminal.outputFlushTimer) { + clearTimeout(terminal.outputFlushTimer); + terminal.outputFlushTimer = null; + } + + if (!terminal.outputBuffer) { + return; + } + + const data = terminal.outputBuffer; + terminal.outputBuffer = ''; + this.sendHiddenOutputToDaemon(terminal, data); + } + /** * Pause the underlying PTY. Under the ptyHost flag, routes the RPC directly * through the supervisor; flag-off uses the legacy `pty.IPty.pause()` path. @@ -487,9 +506,11 @@ export class TerminalPanelManager { this.resumePty(terminal); } } else { - // Hidden output is already present in scrollbackBuffer. Drop any stale - // hidden batch so the renderer can refresh exactly once from getState. - terminal.outputBuffer = ''; + // Hidden output is already present in scrollbackBuffer. Flush any pending + // daemon-only batch first so remote subscribers do not lose the last + // hidden chunk during a visibility transition, then let the renderer + // refresh exactly once from getState. + this.flushPendingHiddenOutputToDaemon(terminal); } } From 30d7048da1bb61d13e87c79186d56bad861f068b Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:40:48 -0700 Subject: [PATCH 022/111] fix: flush buffered output before hiding terminals --- .../src/services/terminalPanelManager.test.ts | 32 +++++++++++++++++++ main/src/services/terminalPanelManager.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/main/src/services/terminalPanelManager.test.ts b/main/src/services/terminalPanelManager.test.ts index 789ae2f5..18b85219 100644 --- a/main/src/services/terminalPanelManager.test.ts +++ b/main/src/services/terminalPanelManager.test.ts @@ -188,4 +188,36 @@ describe('TerminalPanelManager hidden output delivery', () => { expect(terminal.outputFlushTimer).toBeNull(); disposeFlowControlRecord(terminal.flowControl); }); + + it('flushes buffered output to daemon subscribers before hiding a visible panel', () => { + const combinedSink = { send: vi.fn() }; + const daemonSink = { send: vi.fn() }; + setPaneRuntime({ + eventSink: combinedSink, + daemonEventSink: daemonSink, + getConfigManager: () => createConfigManagerStub(), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); + + const manager = new TerminalPanelManager() as unknown as VisibilityAccess; + const terminal = createTerminal({ + isVisible: true, + outputBuffer: 'visible output', + outputFlushTimer: setTimeout(() => undefined, 10_000), + }); + manager.terminals.set(terminal.panelId, terminal); + + manager.setVisibility(terminal.panelId, false); + + expect(combinedSink.send).not.toHaveBeenCalled(); + expect(daemonSink.send).toHaveBeenCalledWith('terminal:output', { + sessionId: 'session-1', + panelId: 'panel-1', + output: 'visible output', + }); + expect(terminal.outputBuffer).toBe(''); + expect(terminal.outputFlushTimer).toBeNull(); + disposeFlowControlRecord(terminal.flowControl); + }); }); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index bd4616fa..cc4a0756 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -499,7 +499,7 @@ export class TerminalPanelManager { if (!isVisible) { // Once hidden, renderer ACKs stop. Do not leave a visible-mode pause // pending against bytes the renderer may never acknowledge. - terminal.outputBuffer = ''; + this.flushPendingHiddenOutputToDaemon(terminal); const wasPaused = terminal.flowControl.isPaused; disposeFlowControlRecord(terminal.flowControl); if (wasPaused) { From 66f142ba2562238be0729329c8eaa6c6b9f327ae Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:52:37 -0700 Subject: [PATCH 023/111] feat: route renderer daemon commands through bridge --- frontend/src/types/electron.d.ts | 5 +- main/src/ipc/daemon.test.ts | 61 ++++++ main/src/ipc/daemon.ts | 23 ++ main/src/ipc/index.ts | 2 + main/src/preload.ts | 355 ++++++++++++++++--------------- 5 files changed, 272 insertions(+), 174 deletions(-) create mode 100644 main/src/ipc/daemon.test.ts create mode 100644 main/src/ipc/daemon.ts diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 068680cc..11f81d0c 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -39,7 +39,8 @@ interface IPCResponse { } interface ElectronAPI { - // Generic invoke method for direct IPC calls + // Generic invoke method. Daemon-owned channels route through the main-process + // daemon bridge while adapter-only channels stay on direct Electron IPC. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic IPC bridge that returns different types based on channel invoke: (channel: string, ...args: unknown[]) => Promise; @@ -460,6 +461,8 @@ interface CloudVmState { // Additional electron interface for IPC event listeners interface ElectronInterface { openExternal: (url: string) => Promise; + // Generic invoke method. Daemon-owned channels route through the main-process + // daemon bridge while adapter-only channels stay on direct Electron IPC. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic IPC bridge that returns different types based on channel invoke: (channel: string, ...args: unknown[]) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic IPC event callback that receives different argument types diff --git a/main/src/ipc/daemon.test.ts b/main/src/ipc/daemon.test.ts new file mode 100644 index 00000000..4504ee87 --- /dev/null +++ b/main/src/ipc/daemon.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { registerDaemonBridgeHandlers } from './daemon'; + +interface IpcMainStub { + handlers: Map Promise>; + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +function createIpcMainStub(): IpcMainStub { + const handlers = new Map Promise>(); + + return { + handlers, + handle(channel, listener) { + handlers.set(channel, listener); + }, + }; +} + +describe('daemon IPC bridge', () => { + it('forwards daemon-owned channels into the shared command registry', async () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + const handler = vi.fn(async (sessionId: string) => ({ success: true, data: sessionId })); + + registry.register('sessions:get', handler); + registerDaemonBridgeHandlers(ipcMain, registry); + + const bridge = ipcMain.handlers.get('daemon:invoke'); + expect(bridge).toBeDefined(); + + await expect(bridge?.({}, 'sessions:get', 'session-1')).resolves.toEqual({ + success: true, + data: 'session-1', + }); + expect(handler).toHaveBeenCalledWith('session-1'); + }); + + it('rejects adapter-only channels at the bridge boundary', async () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerDaemonBridgeHandlers(ipcMain, registry); + + const bridge = ipcMain.handlers.get('daemon:invoke'); + await expect(bridge?.({}, 'sessions:open-ide', 'session-1')).rejects.toThrow( + 'Channel "sessions:open-ide" is not daemon-owned', + ); + }); + + it('rejects malformed bridge requests before reaching the registry', async () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerDaemonBridgeHandlers(ipcMain, registry); + + const bridge = ipcMain.handlers.get('daemon:invoke'); + await expect(bridge?.({}, 123)).rejects.toThrow('Pane daemon bridge requires a string channel'); + }); +}); diff --git a/main/src/ipc/daemon.ts b/main/src/ipc/daemon.ts new file mode 100644 index 00000000..d9e7f7dd --- /dev/null +++ b/main/src/ipc/daemon.ts @@ -0,0 +1,23 @@ +import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; +import type { PaneCommandRegistry } from '../daemon/commandRegistry'; + +interface IpcMainHandleLike { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +export function registerDaemonBridgeHandlers( + ipcMain: IpcMainHandleLike, + commandRegistry: PaneCommandRegistry, +): void { + ipcMain.handle('daemon:invoke', async (_event, channel: unknown, ...args: unknown[]) => { + if (typeof channel !== 'string') { + throw new Error('Pane daemon bridge requires a string channel'); + } + + if (!isDaemonOwnedChannel(channel)) { + throw new Error(`Channel "${channel}" is not daemon-owned`); + } + + return commandRegistry.invoke(channel, args); + }); +} diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 6868f037..f66ae190 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -22,6 +22,7 @@ import { registerCloudHandlers } from './cloud'; import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; +import { registerDaemonBridgeHandlers } from './daemon'; import { PaneCommandRegistry } from '../daemon/commandRegistry'; @@ -50,6 +51,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerClipboardHandlers(ipcMain, services); registerResourceMonitorHandlers(ipcMain, services, commandRegistry); registerOnboardingHandlers(ipcMain, services); + registerDaemonBridgeHandlers(ipcMain, commandRegistry); return commandRegistry; } diff --git a/main/src/preload.ts b/main/src/preload.ts index c3d3f31a..a7198ab4 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -3,6 +3,7 @@ import type { CreateSessionRequest, Session } from './types/session'; import type { AppConfig, UpdateConfigRequest } from './types/config'; import type { CreateProjectRequest, UpdateProjectRequest, Project } from '../../frontend/src/types/project'; import type { ToolPanel } from '../../shared/types/panels'; +import { isDaemonOwnedChannel } from '../../shared/types/daemon'; interface LogEntry { timestamp: string; @@ -271,7 +272,7 @@ if (process.env.NODE_ENV !== 'production') { // Send to main process for file logging try { - ipcRenderer.invoke('console:log', { + invokeIpc('console:log', { level, args: args.map(arg => { if (typeof arg === 'object') { @@ -301,28 +302,36 @@ interface IPCResponse { error?: string; } +function invokeIpc(channel: string, ...args: unknown[]) { + if (isDaemonOwnedChannel(channel)) { + return ipcRenderer.invoke('daemon:invoke', channel, ...args); + } + + return ipcRenderer.invoke(channel, ...args); +} + contextBridge.exposeInMainWorld('electronAPI', { // Generic invoke method for direct IPC calls - invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args), + invoke: (channel: string, ...args: unknown[]) => invokeIpc(channel, ...args), // Basic app info - getAppVersion: () => ipcRenderer.invoke('get-app-version'), - getPlatform: () => ipcRenderer.invoke('get-platform'), - isPackaged: () => ipcRenderer.invoke('is-packaged'), + getAppVersion: () => invokeIpc('get-app-version'), + getPlatform: () => invokeIpc('get-platform'), + isPackaged: () => invokeIpc('is-packaged'), // Version checking - checkForUpdates: (): Promise => ipcRenderer.invoke('version:check-for-updates'), - getVersionInfo: (): Promise => ipcRenderer.invoke('version:get-info'), + checkForUpdates: (): Promise => invokeIpc('version:check-for-updates'), + getVersionInfo: (): Promise => invokeIpc('version:get-info'), // Auto-updater updater: { - checkAndDownload: (): Promise => ipcRenderer.invoke('updater:check-and-download'), - downloadUpdate: (): Promise => ipcRenderer.invoke('updater:download-update'), - installUpdate: (): Promise => ipcRenderer.invoke('updater:install-update'), + checkAndDownload: (): Promise => invokeIpc('updater:check-and-download'), + downloadUpdate: (): Promise => invokeIpc('updater:download-update'), + installUpdate: (): Promise => invokeIpc('updater:install-update'), }, // System utilities - openExternal: (url: string): Promise => ipcRenderer.invoke('openExternal', url), + openExternal: (url: string): Promise => invokeIpc('openExternal', url), diagnostics: { rendererFatal: (payload: { @@ -333,121 +342,121 @@ contextBridge.exposeInMainWorld('electronAPI', { url?: string; line?: number; column?: number; - }): Promise => ipcRenderer.invoke('diagnostics:renderer-fatal', payload), + }): Promise => invokeIpc('diagnostics:renderer-fatal', payload), }, // Session management sessions: { - getAll: (): Promise => ipcRenderer.invoke('sessions:get-all'), - getAllWithProjects: (): Promise => ipcRenderer.invoke('sessions:get-all-with-projects'), - getArchivedWithProjects: (): Promise => ipcRenderer.invoke('sessions:get-archived-with-projects'), - restore: (sessionId: string): Promise => ipcRenderer.invoke('sessions:restore', sessionId), - get: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get', sessionId), - create: (request: CreateSessionRequest): Promise => ipcRenderer.invoke('sessions:create', request), - delete: (sessionId: string): Promise => ipcRenderer.invoke('sessions:delete', sessionId), - sendInput: (sessionId: string, input: string): Promise => ipcRenderer.invoke('sessions:input', sessionId, input), - continue: (sessionId: string, prompt?: string, model?: string): Promise => ipcRenderer.invoke('sessions:continue', sessionId, prompt, model), - getOutput: (sessionId: string, limit?: number): Promise => ipcRenderer.invoke('sessions:get-output', sessionId, limit), - getJsonMessages: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-json-messages', sessionId), - getStatistics: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-statistics', sessionId), - getConversation: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-conversation', sessionId), - getConversationMessages: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-conversation-messages', sessionId), - getConversationMessageCount: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-conversation-message-count', sessionId), - generateCompactedContext: (sessionId: string): Promise => ipcRenderer.invoke('sessions:generate-compacted-context', sessionId), - markViewed: (sessionId: string): Promise => ipcRenderer.invoke('sessions:mark-viewed', sessionId), - stop: (sessionId: string): Promise => ipcRenderer.invoke('sessions:stop', sessionId), + getAll: (): Promise => invokeIpc('sessions:get-all'), + getAllWithProjects: (): Promise => invokeIpc('sessions:get-all-with-projects'), + getArchivedWithProjects: (): Promise => invokeIpc('sessions:get-archived-with-projects'), + restore: (sessionId: string): Promise => invokeIpc('sessions:restore', sessionId), + get: (sessionId: string): Promise => invokeIpc('sessions:get', sessionId), + create: (request: CreateSessionRequest): Promise => invokeIpc('sessions:create', request), + delete: (sessionId: string): Promise => invokeIpc('sessions:delete', sessionId), + sendInput: (sessionId: string, input: string): Promise => invokeIpc('sessions:input', sessionId, input), + continue: (sessionId: string, prompt?: string, model?: string): Promise => invokeIpc('sessions:continue', sessionId, prompt, model), + getOutput: (sessionId: string, limit?: number): Promise => invokeIpc('sessions:get-output', sessionId, limit), + getJsonMessages: (sessionId: string): Promise => invokeIpc('sessions:get-json-messages', sessionId), + getStatistics: (sessionId: string): Promise => invokeIpc('sessions:get-statistics', sessionId), + getConversation: (sessionId: string): Promise => invokeIpc('sessions:get-conversation', sessionId), + getConversationMessages: (sessionId: string): Promise => invokeIpc('sessions:get-conversation-messages', sessionId), + getConversationMessageCount: (sessionId: string): Promise => invokeIpc('sessions:get-conversation-message-count', sessionId), + generateCompactedContext: (sessionId: string): Promise => invokeIpc('sessions:generate-compacted-context', sessionId), + markViewed: (sessionId: string): Promise => invokeIpc('sessions:mark-viewed', sessionId), + stop: (sessionId: string): Promise => invokeIpc('sessions:stop', sessionId), // Execution and Git operations - getExecutions: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-executions', sessionId), - getExecutionDiff: (sessionId: string, executionId: string): Promise => ipcRenderer.invoke('sessions:get-execution-diff', sessionId, executionId), - gitCommit: (sessionId: string, message: string): Promise => ipcRenderer.invoke('sessions:git-commit', sessionId, message), - gitDiff: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-diff', sessionId), - getCombinedDiff: (sessionId: string, executionIds?: number[]): Promise => ipcRenderer.invoke('sessions:get-combined-diff', sessionId, executionIds), - getCommitDiffByHash: (sessionId: string, commitHash: string): Promise => ipcRenderer.invoke('sessions:get-commit-diff-by-hash', sessionId, commitHash), + getExecutions: (sessionId: string): Promise => invokeIpc('sessions:get-executions', sessionId), + getExecutionDiff: (sessionId: string, executionId: string): Promise => invokeIpc('sessions:get-execution-diff', sessionId, executionId), + gitCommit: (sessionId: string, message: string): Promise => invokeIpc('sessions:git-commit', sessionId, message), + gitDiff: (sessionId: string): Promise => invokeIpc('sessions:git-diff', sessionId), + getCombinedDiff: (sessionId: string, executionIds?: number[]): Promise => invokeIpc('sessions:get-combined-diff', sessionId, executionIds), + getCommitDiffByHash: (sessionId: string, commitHash: string): Promise => invokeIpc('sessions:get-commit-diff-by-hash', sessionId, commitHash), // Main repo session - getOrCreateMainRepoSession: (projectId: number): Promise => ipcRenderer.invoke('sessions:get-or-create-main-repo', projectId), + getOrCreateMainRepoSession: (projectId: number): Promise => invokeIpc('sessions:get-or-create-main-repo', projectId), // Script operations - hasRunScript: (sessionId: string): Promise => ipcRenderer.invoke('sessions:has-run-script', sessionId), - getRunningSession: (): Promise => ipcRenderer.invoke('sessions:get-running-session'), - runScript: (sessionId: string): Promise => ipcRenderer.invoke('sessions:run-script', sessionId), - stopScript: (sessionId?: string): Promise => ipcRenderer.invoke('sessions:stop-script', sessionId), - runTerminalCommand: (sessionId: string, command: string): Promise => ipcRenderer.invoke('sessions:run-terminal-command', sessionId, command), - sendTerminalInput: (sessionId: string, data: string): Promise => ipcRenderer.invoke('sessions:send-terminal-input', sessionId, data), - preCreateTerminal: (sessionId: string): Promise => ipcRenderer.invoke('sessions:pre-create-terminal', sessionId), - resizeTerminal: (sessionId: string, cols: number, rows: number): Promise => ipcRenderer.invoke('sessions:resize-terminal', sessionId, cols, rows), + hasRunScript: (sessionId: string): Promise => invokeIpc('sessions:has-run-script', sessionId), + getRunningSession: (): Promise => invokeIpc('sessions:get-running-session'), + runScript: (sessionId: string): Promise => invokeIpc('sessions:run-script', sessionId), + stopScript: (sessionId?: string): Promise => invokeIpc('sessions:stop-script', sessionId), + runTerminalCommand: (sessionId: string, command: string): Promise => invokeIpc('sessions:run-terminal-command', sessionId, command), + sendTerminalInput: (sessionId: string, data: string): Promise => invokeIpc('sessions:send-terminal-input', sessionId, data), + preCreateTerminal: (sessionId: string): Promise => invokeIpc('sessions:pre-create-terminal', sessionId), + resizeTerminal: (sessionId: string, cols: number, rows: number): Promise => invokeIpc('sessions:resize-terminal', sessionId, cols, rows), // Prompt operations - getPrompts: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-prompts', sessionId), + getPrompts: (sessionId: string): Promise => invokeIpc('sessions:get-prompts', sessionId), // Git rebase operations - rebaseMainIntoWorktree: (sessionId: string): Promise => ipcRenderer.invoke('sessions:rebase-main-into-worktree', sessionId), - abortRebaseAndUseClaude: (sessionId: string): Promise => ipcRenderer.invoke('sessions:abort-rebase-and-use-claude', sessionId), - squashAndRebaseToMain: (sessionId: string, commitMessage: string): Promise => ipcRenderer.invoke('sessions:squash-and-rebase-to-main', sessionId, commitMessage), - rebaseToMain: (sessionId: string): Promise => ipcRenderer.invoke('sessions:rebase-to-main', sessionId), + rebaseMainIntoWorktree: (sessionId: string): Promise => invokeIpc('sessions:rebase-main-into-worktree', sessionId), + abortRebaseAndUseClaude: (sessionId: string): Promise => invokeIpc('sessions:abort-rebase-and-use-claude', sessionId), + squashAndRebaseToMain: (sessionId: string, commitMessage: string): Promise => invokeIpc('sessions:squash-and-rebase-to-main', sessionId, commitMessage), + rebaseToMain: (sessionId: string): Promise => invokeIpc('sessions:rebase-to-main', sessionId), // Git pull/push operations - gitPull: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-pull', sessionId), - gitPush: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-push', sessionId), - gitFetch: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-fetch', sessionId), - gitStash: (sessionId: string, message?: string): Promise => ipcRenderer.invoke('sessions:git-stash', sessionId, message), - gitStashPop: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-stash-pop', sessionId), - gitSoftReset: (sessionId: string): Promise => ipcRenderer.invoke('sessions:git-soft-reset', sessionId), - gitStageAndCommit: (sessionId: string, message: string): Promise => ipcRenderer.invoke('sessions:git-stage-and-commit', sessionId, message), - hasStash: (sessionId: string): Promise => ipcRenderer.invoke('sessions:has-stash', sessionId), - setUpstream: (sessionId: string, remoteBranch: string): Promise => ipcRenderer.invoke('sessions:set-upstream', sessionId, remoteBranch), - getUpstream: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-upstream', sessionId), - getRemoteBranches: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-remote-branches', sessionId), - getGitStatus: (sessionId: string, nonBlocking?: boolean, isInitialLoad?: boolean): Promise => ipcRenderer.invoke('sessions:get-git-status', sessionId, nonBlocking, isInitialLoad), - getLastCommits: (sessionId: string, count: number): Promise => ipcRenderer.invoke('sessions:get-last-commits', sessionId, count), - getGitGraph: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-git-graph', sessionId), + gitPull: (sessionId: string): Promise => invokeIpc('sessions:git-pull', sessionId), + gitPush: (sessionId: string): Promise => invokeIpc('sessions:git-push', sessionId), + gitFetch: (sessionId: string): Promise => invokeIpc('sessions:git-fetch', sessionId), + gitStash: (sessionId: string, message?: string): Promise => invokeIpc('sessions:git-stash', sessionId, message), + gitStashPop: (sessionId: string): Promise => invokeIpc('sessions:git-stash-pop', sessionId), + gitSoftReset: (sessionId: string): Promise => invokeIpc('sessions:git-soft-reset', sessionId), + gitStageAndCommit: (sessionId: string, message: string): Promise => invokeIpc('sessions:git-stage-and-commit', sessionId, message), + hasStash: (sessionId: string): Promise => invokeIpc('sessions:has-stash', sessionId), + setUpstream: (sessionId: string, remoteBranch: string): Promise => invokeIpc('sessions:set-upstream', sessionId, remoteBranch), + getUpstream: (sessionId: string): Promise => invokeIpc('sessions:get-upstream', sessionId), + getRemoteBranches: (sessionId: string): Promise => invokeIpc('sessions:get-remote-branches', sessionId), + getGitStatus: (sessionId: string, nonBlocking?: boolean, isInitialLoad?: boolean): Promise => invokeIpc('sessions:get-git-status', sessionId, nonBlocking, isInitialLoad), + getLastCommits: (sessionId: string, count: number): Promise => invokeIpc('sessions:get-last-commits', sessionId, count), + getGitGraph: (sessionId: string): Promise => invokeIpc('sessions:get-git-graph', sessionId), // Git operation helpers - hasChangesToRebase: (sessionId: string): Promise => ipcRenderer.invoke('sessions:has-changes-to-rebase', sessionId), - getGitCommands: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-git-commands', sessionId), - generateName: (prompt: string): Promise => ipcRenderer.invoke('sessions:generate-name', prompt), - rename: (sessionId: string, newName: string): Promise => ipcRenderer.invoke('sessions:rename', sessionId, newName), - toggleFavorite: (sessionId: string): Promise => ipcRenderer.invoke('sessions:toggle-favorite', sessionId), + hasChangesToRebase: (sessionId: string): Promise => invokeIpc('sessions:has-changes-to-rebase', sessionId), + getGitCommands: (sessionId: string): Promise => invokeIpc('sessions:get-git-commands', sessionId), + generateName: (prompt: string): Promise => invokeIpc('sessions:generate-name', prompt), + rename: (sessionId: string, newName: string): Promise => invokeIpc('sessions:rename', sessionId, newName), + toggleFavorite: (sessionId: string): Promise => invokeIpc('sessions:toggle-favorite', sessionId), // Resume session operations - getResumable: (): Promise => ipcRenderer.invoke('sessions:get-resumable'), - resumeInterrupted: (sessionIds: string[]): Promise => ipcRenderer.invoke('sessions:resume-interrupted', sessionIds), - dismissInterrupted: (sessionIds: string[]): Promise => ipcRenderer.invoke('sessions:dismiss-interrupted', sessionIds), + getResumable: (): Promise => invokeIpc('sessions:get-resumable'), + resumeInterrupted: (sessionIds: string[]): Promise => invokeIpc('sessions:resume-interrupted', sessionIds), + dismissInterrupted: (sessionIds: string[]): Promise => invokeIpc('sessions:dismiss-interrupted', sessionIds), // IDE operations - openIDE: (sessionId: string, ideKey?: string): Promise => ipcRenderer.invoke('sessions:open-ide', sessionId, ideKey), + openIDE: (sessionId: string, ideKey?: string): Promise => invokeIpc('sessions:open-ide', sessionId, ideKey), // Reorder operations - reorder: (sessionOrders: Array<{ id: string; displayOrder: number }>): Promise => ipcRenderer.invoke('sessions:reorder', sessionOrders), + reorder: (sessionOrders: Array<{ id: string; displayOrder: number }>): Promise => invokeIpc('sessions:reorder', sessionOrders), // Image operations - saveImages: (sessionId: string, images: Array<{ name: string; dataUrl: string; type: string }>): Promise => ipcRenderer.invoke('sessions:save-images', sessionId, images), + saveImages: (sessionId: string, images: Array<{ name: string; dataUrl: string; type: string }>): Promise => invokeIpc('sessions:save-images', sessionId, images), // Text file operations - saveLargeText: (sessionId: string, text: string): Promise => ipcRenderer.invoke('sessions:save-large-text', sessionId, text), + saveLargeText: (sessionId: string, text: string): Promise => invokeIpc('sessions:save-large-text', sessionId, text), // Log operations - getLogs: (sessionId: string): Promise => ipcRenderer.invoke('sessions:get-logs', sessionId), - clearLogs: (sessionId: string): Promise => ipcRenderer.invoke('sessions:clear-logs', sessionId), - addLog: (sessionId: string, entry: LogEntry): Promise => ipcRenderer.invoke('sessions:add-log', sessionId, entry), + getLogs: (sessionId: string): Promise => invokeIpc('sessions:get-logs', sessionId), + clearLogs: (sessionId: string): Promise => invokeIpc('sessions:clear-logs', sessionId), + addLog: (sessionId: string, entry: LogEntry): Promise => invokeIpc('sessions:add-log', sessionId, entry), }, // Project management projects: { - getAll: (): Promise => ipcRenderer.invoke('projects:get-all'), - getActive: (): Promise => ipcRenderer.invoke('projects:get-active'), - create: (projectData: CreateProjectRequest): Promise => ipcRenderer.invoke('projects:create', projectData), - activate: (projectId: string): Promise => ipcRenderer.invoke('projects:activate', projectId), - update: (projectId: string, updates: UpdateProjectRequest): Promise => ipcRenderer.invoke('projects:update', projectId, updates), - delete: (projectId: string): Promise => ipcRenderer.invoke('projects:delete', projectId), - detectBranch: (path: string): Promise => ipcRenderer.invoke('projects:detect-branch', path), - reorder: (projectOrders: Array<{ id: number; displayOrder: number }>): Promise => ipcRenderer.invoke('projects:reorder', projectOrders), - listBranches: (projectId: string): Promise => ipcRenderer.invoke('projects:list-branches', projectId), - refreshGitStatus: (projectId: number): Promise => ipcRenderer.invoke('projects:refresh-git-status', projectId), - runScript: (projectId: number): Promise => ipcRenderer.invoke('projects:run-script', projectId), - getRunningScript: (): Promise => ipcRenderer.invoke('projects:get-running-script'), - stopScript: (projectId?: number): Promise => ipcRenderer.invoke('projects:stop-script', projectId), + getAll: (): Promise => invokeIpc('projects:get-all'), + getActive: (): Promise => invokeIpc('projects:get-active'), + create: (projectData: CreateProjectRequest): Promise => invokeIpc('projects:create', projectData), + activate: (projectId: string): Promise => invokeIpc('projects:activate', projectId), + update: (projectId: string, updates: UpdateProjectRequest): Promise => invokeIpc('projects:update', projectId, updates), + delete: (projectId: string): Promise => invokeIpc('projects:delete', projectId), + detectBranch: (path: string): Promise => invokeIpc('projects:detect-branch', path), + reorder: (projectOrders: Array<{ id: number; displayOrder: number }>): Promise => invokeIpc('projects:reorder', projectOrders), + listBranches: (projectId: string): Promise => invokeIpc('projects:list-branches', projectId), + refreshGitStatus: (projectId: number): Promise => invokeIpc('projects:refresh-git-status', projectId), + runScript: (projectId: number): Promise => invokeIpc('projects:run-script', projectId), + getRunningScript: (): Promise => invokeIpc('projects:get-running-script'), + stopScript: (projectId?: number): Promise => invokeIpc('projects:stop-script', projectId), /** * Detects the project's config file (pane.json, conductor.json, .gitpod.yml, or * devcontainer.json) and returns a `DetectedProjectConfig` with `setup`, `run`, @@ -455,7 +464,7 @@ contextBridge.exposeInMainWorld('electronAPI', { * Used by ProjectSettings to show "From " badges on script fields. * Reads from the project's main working directory, not a session worktree. */ - detectConfig: (projectId: string): Promise => ipcRenderer.invoke('projects:detect-config', projectId), + detectConfig: (projectId: string): Promise => invokeIpc('projects:detect-config', projectId), /** * Resolves which run script should execute for a specific session. * Checks (in order): DB `project.run_script`, then config-file detection from the @@ -464,78 +473,78 @@ contextBridge.exposeInMainWorld('electronAPI', { * Returns `{ command, source }` or null if nothing is configured. * Used by `PanelTabBar` to start/stop the dev server for a session. */ - resolveRunScript: (sessionId: string): Promise => ipcRenderer.invoke('projects:resolve-run-script', sessionId), + resolveRunScript: (sessionId: string): Promise => invokeIpc('projects:resolve-run-script', sessionId), }, // Git operations git: { - detectBranch: (path: string): Promise> => ipcRenderer.invoke('projects:detect-branch', path), - cancelStatusForProject: (projectId: number): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:cancel-status-for-project', projectId), - executeProject: (projectId: number, args: string[]): Promise => ipcRenderer.invoke('git:execute-project', { projectId, args }), - cloneRepo: (url: string, destDir: string): Promise => ipcRenderer.invoke('git:clone-repo', url, destDir), + detectBranch: (path: string): Promise> => invokeIpc('projects:detect-branch', path), + cancelStatusForProject: (projectId: number): Promise<{ success: boolean; error?: string }> => invokeIpc('git:cancel-status-for-project', projectId), + executeProject: (projectId: number, args: string[]): Promise => invokeIpc('git:execute-project', { projectId, args }), + cloneRepo: (url: string, destDir: string): Promise => invokeIpc('git:clone-repo', url, destDir), }, // Folders folders: { - getByProject: (projectId: number): Promise => ipcRenderer.invoke('folders:get-by-project', projectId), - create: (name: string, projectId: number, parentFolderId?: string | null): Promise => ipcRenderer.invoke('folders:create', name, projectId, parentFolderId), - update: (folderId: string, updates: { name?: string; display_order?: number; parent_folder_id?: string | null }): Promise => ipcRenderer.invoke('folders:update', folderId, updates), - delete: (folderId: string): Promise => ipcRenderer.invoke('folders:delete', folderId), - reorder: (projectId: number, folderOrders: Array<{ id: string; displayOrder: number }>): Promise => ipcRenderer.invoke('folders:reorder', projectId, folderOrders), - moveSession: (sessionId: string, folderId: string | null): Promise => ipcRenderer.invoke('folders:move-session', sessionId, folderId), - move: (folderId: string, parentFolderId: string | null): Promise => ipcRenderer.invoke('folders:move', folderId, parentFolderId), + getByProject: (projectId: number): Promise => invokeIpc('folders:get-by-project', projectId), + create: (name: string, projectId: number, parentFolderId?: string | null): Promise => invokeIpc('folders:create', name, projectId, parentFolderId), + update: (folderId: string, updates: { name?: string; display_order?: number; parent_folder_id?: string | null }): Promise => invokeIpc('folders:update', folderId, updates), + delete: (folderId: string): Promise => invokeIpc('folders:delete', folderId), + reorder: (projectId: number, folderOrders: Array<{ id: string; displayOrder: number }>): Promise => invokeIpc('folders:reorder', projectId, folderOrders), + moveSession: (sessionId: string, folderId: string | null): Promise => invokeIpc('folders:move-session', sessionId, folderId), + move: (folderId: string, parentFolderId: string | null): Promise => invokeIpc('folders:move', folderId, parentFolderId), }, // Configuration config: { - get: (): Promise => ipcRenderer.invoke('config:get'), - update: (updates: UpdateConfigRequest): Promise => ipcRenderer.invoke('config:update', updates), - getSessionPreferences: (): Promise => ipcRenderer.invoke('config:get-session-preferences'), - updateSessionPreferences: (preferences: AppConfig['sessionCreationPreferences']): Promise => ipcRenderer.invoke('config:update-session-preferences', preferences), - getAvailableShells: (): Promise => ipcRenderer.invoke('config:get-available-shells'), - getMonospaceFonts: (): Promise => ipcRenderer.invoke('config:get-monospace-fonts'), + get: (): Promise => invokeIpc('config:get'), + update: (updates: UpdateConfigRequest): Promise => invokeIpc('config:update', updates), + getSessionPreferences: (): Promise => invokeIpc('config:get-session-preferences'), + updateSessionPreferences: (preferences: AppConfig['sessionCreationPreferences']): Promise => invokeIpc('config:update-session-preferences', preferences), + getAvailableShells: (): Promise => invokeIpc('config:get-available-shells'), + getMonospaceFonts: (): Promise => invokeIpc('config:get-monospace-fonts'), }, // Prompts prompts: { - getAll: (): Promise => ipcRenderer.invoke('prompts:get-all'), - getByPromptId: (promptId: string): Promise => ipcRenderer.invoke('prompts:get-by-id', promptId), + getAll: (): Promise => invokeIpc('prompts:get-all'), + getByPromptId: (promptId: string): Promise => invokeIpc('prompts:get-by-id', promptId), }, // File operations file: { - listProject: (projectId: number, path?: string): Promise => ipcRenderer.invoke('file:list-project', { projectId, path }), - readProject: (projectId: number, filePath: string): Promise => ipcRenderer.invoke('file:read-project', { projectId, filePath }), - writeProject: (projectId: number, filePath: string, content: string): Promise => ipcRenderer.invoke('file:write-project', { projectId, filePath, content }), + listProject: (projectId: number, path?: string): Promise => invokeIpc('file:list-project', { projectId, path }), + readProject: (projectId: number, filePath: string): Promise => invokeIpc('file:read-project', { projectId, filePath }), + writeProject: (projectId: number, filePath: string, content: string): Promise => invokeIpc('file:write-project', { projectId, filePath, content }), }, // Dialog dialog: { - openFile: (options?: DialogOptions): Promise> => ipcRenderer.invoke('dialog:open-file', options), - openDirectory: (options?: DialogOptions): Promise> => ipcRenderer.invoke('dialog:open-directory', options), + openFile: (options?: DialogOptions): Promise> => invokeIpc('dialog:open-file', options), + openDirectory: (options?: DialogOptions): Promise> => invokeIpc('dialog:open-directory', options), }, // Permissions permissions: { - respond: (requestId: string, response: boolean | { approved: boolean; remember?: boolean }): Promise => ipcRenderer.invoke('permission:respond', requestId, response), - getPending: (): Promise => ipcRenderer.invoke('permission:getPending'), + respond: (requestId: string, response: boolean | { approved: boolean; remember?: boolean }): Promise => invokeIpc('permission:respond', requestId, response), + getPending: (): Promise => invokeIpc('permission:getPending'), }, // Stravu OAuth integration stravu: { - getConnectionStatus: (): Promise => ipcRenderer.invoke('stravu:get-connection-status'), - initiateAuth: (): Promise => ipcRenderer.invoke('stravu:initiate-auth'), - checkAuthStatus: (sessionId: string): Promise => ipcRenderer.invoke('stravu:check-auth-status', sessionId), - disconnect: (): Promise => ipcRenderer.invoke('stravu:disconnect'), - getNotebooks: (): Promise => ipcRenderer.invoke('stravu:get-notebooks'), - getNotebook: (notebookId: string): Promise => ipcRenderer.invoke('stravu:get-notebook', notebookId), - searchNotebooks: (query: string, limit?: number): Promise => ipcRenderer.invoke('stravu:search-notebooks', query, limit), + getConnectionStatus: (): Promise => invokeIpc('stravu:get-connection-status'), + initiateAuth: (): Promise => invokeIpc('stravu:initiate-auth'), + checkAuthStatus: (sessionId: string): Promise => invokeIpc('stravu:check-auth-status', sessionId), + disconnect: (): Promise => invokeIpc('stravu:disconnect'), + getNotebooks: (): Promise => invokeIpc('stravu:get-notebooks'), + getNotebook: (notebookId: string): Promise => invokeIpc('stravu:get-notebook', notebookId), + searchNotebooks: (query: string, limit?: number): Promise => invokeIpc('stravu:search-notebooks', query, limit), }, // Dashboard dashboard: { - getProjectStatus: (projectId: number): Promise => ipcRenderer.invoke('dashboard:get-project-status', projectId), - getProjectStatusProgressive: (projectId: number): Promise => ipcRenderer.invoke('dashboard:get-project-status-progressive', projectId), + getProjectStatus: (projectId: number): Promise => invokeIpc('dashboard:get-project-status', projectId), + getProjectStatusProgressive: (projectId: number): Promise => invokeIpc('dashboard:get-project-status-progressive', projectId), onUpdate: (callback: (data: DashboardUpdateData) => void) => { const subscription = (_event: Electron.IpcRendererEvent, data: DashboardUpdateData) => callback(data); ipcRenderer.on('dashboard:update', subscription); @@ -550,18 +559,18 @@ contextBridge.exposeInMainWorld('electronAPI', { // Onboarding onboarding: { - detectEnvironment: (): Promise => ipcRenderer.invoke('onboarding:detect-environment'), - setupDefaultRepo: (): Promise => ipcRenderer.invoke('onboarding:setup-default-repo'), - starRepo: (): Promise => ipcRenderer.invoke('onboarding:star-repo'), + detectEnvironment: (): Promise => invokeIpc('onboarding:detect-environment'), + setupDefaultRepo: (): Promise => invokeIpc('onboarding:setup-default-repo'), + starRepo: (): Promise => invokeIpc('onboarding:star-repo'), }, // UI State management uiState: { - getExpanded: (): Promise => ipcRenderer.invoke('ui-state:get-expanded'), - saveExpanded: (projectIds: number[], folderIds: string[]): Promise => ipcRenderer.invoke('ui-state:save-expanded', projectIds, folderIds), - saveExpandedProjects: (projectIds: number[]): Promise => ipcRenderer.invoke('ui-state:save-expanded-projects', projectIds), - saveExpandedFolders: (folderIds: string[]): Promise => ipcRenderer.invoke('ui-state:save-expanded-folders', folderIds), - saveSessionSortAscending: (ascending: boolean): Promise => ipcRenderer.invoke('ui-state:save-session-sort-ascending', ascending), + getExpanded: (): Promise => invokeIpc('ui-state:get-expanded'), + saveExpanded: (projectIds: number[], folderIds: string[]): Promise => invokeIpc('ui-state:save-expanded', projectIds, folderIds), + saveExpandedProjects: (projectIds: number[]): Promise => invokeIpc('ui-state:save-expanded-projects', projectIds), + saveExpandedFolders: (folderIds: string[]): Promise => invokeIpc('ui-state:save-expanded-folders', folderIds), + saveSessionSortAscending: (ascending: boolean): Promise => invokeIpc('ui-state:save-session-sort-ascending', ascending), }, // Event listeners for real-time updates @@ -806,42 +815,42 @@ contextBridge.exposeInMainWorld('electronAPI', { // Panels API for Claude panels and other panel types panels: { createPanel: (sessionId: string, type: string, name: string, config?: Record): Promise => - ipcRenderer.invoke('panels:create', { sessionId, type, title: name, initialState: config }), - getSessionPanels: (sessionId: string): Promise => ipcRenderer.invoke('panels:list', sessionId), - deletePanel: (panelId: string): Promise => ipcRenderer.invoke('panels:delete', panelId), - renamePanel: (panelId: string, name: string): Promise => ipcRenderer.invoke('panels:update', panelId, { name }), - setActivePanel: (sessionId: string, panelId: string): Promise => ipcRenderer.invoke('panels:set-active', sessionId, panelId), - resizeTerminal: (panelId: string, cols: number, rows: number): Promise => ipcRenderer.invoke('panels:resize-terminal', panelId, cols, rows), - sendTerminalInput: (panelId: string, data: string): Promise => ipcRenderer.invoke('panels:send-terminal-input', panelId, data), - getOutput: (panelId: string, limit?: number): Promise => ipcRenderer.invoke('panels:get-output', panelId, limit), - getConversationMessages: (panelId: string): Promise => ipcRenderer.invoke('panels:get-conversation-messages', panelId), - getJsonMessages: (panelId: string): Promise => ipcRenderer.invoke('panels:get-json-messages', panelId), - getPrompts: (panelId: string): Promise => ipcRenderer.invoke('panels:get-prompts', panelId), - sendInput: (panelId: string, input: string): Promise => ipcRenderer.invoke('panels:send-input', panelId, input), - continue: (panelId: string, input: string, model?: string): Promise => ipcRenderer.invoke('panels:continue', panelId, input, model), + invokeIpc('panels:create', { sessionId, type, title: name, initialState: config }), + getSessionPanels: (sessionId: string): Promise => invokeIpc('panels:list', sessionId), + deletePanel: (panelId: string): Promise => invokeIpc('panels:delete', panelId), + renamePanel: (panelId: string, name: string): Promise => invokeIpc('panels:update', panelId, { name }), + setActivePanel: (sessionId: string, panelId: string): Promise => invokeIpc('panels:set-active', sessionId, panelId), + resizeTerminal: (panelId: string, cols: number, rows: number): Promise => invokeIpc('panels:resize-terminal', panelId, cols, rows), + sendTerminalInput: (panelId: string, data: string): Promise => invokeIpc('panels:send-terminal-input', panelId, data), + getOutput: (panelId: string, limit?: number): Promise => invokeIpc('panels:get-output', panelId, limit), + getConversationMessages: (panelId: string): Promise => invokeIpc('panels:get-conversation-messages', panelId), + getJsonMessages: (panelId: string): Promise => invokeIpc('panels:get-json-messages', panelId), + getPrompts: (panelId: string): Promise => invokeIpc('panels:get-prompts', panelId), + sendInput: (panelId: string, input: string): Promise => invokeIpc('panels:send-input', panelId, input), + continue: (panelId: string, input: string, model?: string): Promise => invokeIpc('panels:continue', panelId, input, model), }, // Logs panel operations logs: { - runScript: (sessionId: string, command: string, cwd: string): Promise => ipcRenderer.invoke('logs:runScript', sessionId, command, cwd), - stopScript: (panelId: string): Promise => ipcRenderer.invoke('logs:stopScript', panelId), - isRunning: (sessionId: string): Promise => ipcRenderer.invoke('logs:isRunning', sessionId), + runScript: (sessionId: string, command: string, cwd: string): Promise => invokeIpc('logs:runScript', sessionId, command, cwd), + stopScript: (panelId: string): Promise => invokeIpc('logs:stopScript', panelId), + isRunning: (sessionId: string): Promise => invokeIpc('logs:isRunning', sessionId), }, // Debug utilities debug: { - getTableStructure: (tableName: 'folders' | 'sessions'): Promise => ipcRenderer.invoke('debug:get-table-structure', tableName), + getTableStructure: (tableName: 'folders' | 'sessions'): Promise => invokeIpc('debug:get-table-structure', tableName), }, // Nimbalyst integration nimbalyst: { - checkInstalled: (): Promise => ipcRenderer.invoke('nimbalyst:check-installed'), - openWorktree: (worktreePath: string): Promise => ipcRenderer.invoke('nimbalyst:open-worktree', worktreePath), + checkInstalled: (): Promise => invokeIpc('nimbalyst:check-installed'), + openWorktree: (worktreePath: string): Promise => invokeIpc('nimbalyst:open-worktree', worktreePath), }, // Analytics tracking analytics: { - getIdentity: () => ipcRenderer.invoke('analytics:get-identity'), + getIdentity: () => invokeIpc('analytics:get-identity'), onMainEvent: (callback: (event: { eventName: string; properties: Record }) => void) => { // Replay any events that arrived before this callback was registered for (const buffered of analyticsEventBuffer) { @@ -860,20 +869,20 @@ contextBridge.exposeInMainWorld('electronAPI', { // Spotlight spotlight: { - enable: (sessionId: string): Promise => ipcRenderer.invoke('spotlight:enable', sessionId), - disable: (sessionId: string): Promise => ipcRenderer.invoke('spotlight:disable', sessionId), - getStatus: (projectId: number): Promise => ipcRenderer.invoke('spotlight:get-status', projectId), + enable: (sessionId: string): Promise => invokeIpc('spotlight:enable', sessionId), + disable: (sessionId: string): Promise => invokeIpc('spotlight:disable', sessionId), + getStatus: (projectId: number): Promise => invokeIpc('spotlight:get-status', projectId), }, // Cloud VM management cloud: { - getState: (): Promise => ipcRenderer.invoke('cloud:get-state'), - startVm: (): Promise => ipcRenderer.invoke('cloud:start-vm'), - stopVm: (): Promise => ipcRenderer.invoke('cloud:stop-vm'), - startTunnel: (): Promise => ipcRenderer.invoke('cloud:start-tunnel'), - stopTunnel: (): Promise => ipcRenderer.invoke('cloud:stop-tunnel'), - startPolling: (): Promise => ipcRenderer.invoke('cloud:start-polling'), - stopPolling: (): Promise => ipcRenderer.invoke('cloud:stop-polling'), + getState: (): Promise => invokeIpc('cloud:get-state'), + startVm: (): Promise => invokeIpc('cloud:start-vm'), + stopVm: (): Promise => invokeIpc('cloud:stop-vm'), + startTunnel: (): Promise => invokeIpc('cloud:start-tunnel'), + stopTunnel: (): Promise => invokeIpc('cloud:stop-tunnel'), + startPolling: (): Promise => invokeIpc('cloud:start-polling'), + stopPolling: (): Promise => invokeIpc('cloud:stop-polling'), onStateChanged: (callback: (state: unknown) => void): (() => void) => { const wrappedCallback = (_event: unknown, state: unknown) => callback(state); ipcRenderer.on('cloud:state-changed', wrappedCallback); @@ -883,14 +892,14 @@ contextBridge.exposeInMainWorld('electronAPI', { // Resource monitor resourceMonitor: { - getSnapshot: (): Promise => ipcRenderer.invoke('resource-monitor:get-snapshot'), - startActive: (): Promise => ipcRenderer.invoke('resource-monitor:start-active'), - stopActive: (): Promise => ipcRenderer.invoke('resource-monitor:stop-active'), + getSnapshot: (): Promise => invokeIpc('resource-monitor:get-snapshot'), + startActive: (): Promise => invokeIpc('resource-monitor:start-active'), + stopActive: (): Promise => invokeIpc('resource-monitor:stop-active'), }, // Window state queries (invoke, not event subscriptions) window: { - isFocused: (): Promise => ipcRenderer.invoke('window:is-focused') as Promise, + isFocused: (): Promise => invokeIpc('window:is-focused') as Promise, }, // ptyHost: typed wrapper over the per-window MessagePort. The raw port is @@ -948,8 +957,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Expose electron event listeners and utilities for permission requests contextBridge.exposeInMainWorld('electron', { - openExternal: (url: string) => ipcRenderer.invoke('openExternal', url), - invoke: (channel: string, ...args: unknown[]) => ipcRenderer.invoke(channel, ...args), + openExternal: (url: string) => invokeIpc('openExternal', url), + invoke: (channel: string, ...args: unknown[]) => invokeIpc(channel, ...args), on: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ 'permission:request' From 4305372808d215fa5d18cb70450fbe141c703f64 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 12:54:32 -0700 Subject: [PATCH 024/111] test: harden daemon bridge boundary checks --- main/src/core/importBoundary.test.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 48813878..c22ea438 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -46,11 +46,28 @@ describe('daemon/client import boundary', () => { }); it('keeps the daemon server free of Electron bootstrap imports', () => { - const source = readMainSrcFile('daemon/server.ts'); + const daemonBoundaryFiles = [ + 'daemon/server.ts', + 'ipc/daemon.ts', + ]; + + for (const relativePath of daemonBoundaryFiles) { + const source = readMainSrcFile(relativePath); + + expect(source, relativePath).not.toContain("from 'electron'"); + expect(source, relativePath).not.toContain('mainWindow'); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); + expect(source, relativePath).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + } + }); + + it('routes daemon-owned preload invokes through the shared bridge helper', () => { + const source = readMainSrcFile('preload.ts'); - expect(source).not.toContain("from 'electron'"); - expect(source).not.toContain('mainWindow'); - expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)['"]/); - expect(source).not.toMatch(/from ['"](?:\.\.\/)+(?:index)\.ts['"]/); + expect(source).toContain('isDaemonOwnedChannel'); + expect(source).toContain("ipcRenderer.invoke('daemon:invoke', channel, ...args)"); + expect(source).not.toMatch( + /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, + ); }); }); From 8afaeb608672660ffc2a7910900c785b0d178f31 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 13:29:58 -0700 Subject: [PATCH 025/111] fix: restore live Electron daemon bridge boot --- main/src/core/importBoundary.test.ts | 5 ++- main/src/daemon/commandRegistry.ts | 2 +- main/src/daemon/daemonChannels.ts | 57 +++++++++++++++++++++++++ main/src/daemon/socketFraming.test.ts | 2 +- main/src/index.ts | 9 ++-- main/src/ipc/daemon.ts | 2 +- main/src/preload.ts | 61 ++++++++++++++++++++++++++- shared/types/daemon.ts | 58 ------------------------- 8 files changed, 128 insertions(+), 68 deletions(-) create mode 100644 main/src/daemon/daemonChannels.ts diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index c22ea438..91284bab 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -61,10 +61,13 @@ describe('daemon/client import boundary', () => { } }); - it('routes daemon-owned preload invokes through the shared bridge helper', () => { + it('routes daemon-owned preload invokes through the runtime-safe bridge helper', () => { const source = readMainSrcFile('preload.ts'); + expect(source).toContain('function isDaemonOwnedChannel'); expect(source).toContain('isDaemonOwnedChannel'); + expect(source).not.toContain("../../shared/types/daemon"); + expect(source).not.toContain("from './daemon/daemonChannels'"); expect(source).toContain("ipcRenderer.invoke('daemon:invoke', channel, ...args)"); expect(source).not.toMatch( /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, diff --git a/main/src/daemon/commandRegistry.ts b/main/src/daemon/commandRegistry.ts index 2de857f4..b34062c0 100644 --- a/main/src/daemon/commandRegistry.ts +++ b/main/src/daemon/commandRegistry.ts @@ -1,4 +1,4 @@ -import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from './daemonChannels'; export type PaneCommandHandler = ( ...args: TArgs diff --git a/main/src/daemon/daemonChannels.ts b/main/src/daemon/daemonChannels.ts new file mode 100644 index 00000000..e13ae658 --- /dev/null +++ b/main/src/daemon/daemonChannels.ts @@ -0,0 +1,57 @@ +const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ + 'file:showInFolder', + 'sessions:open-ide', + 'sessions:set-active-session', + 'terminal:clipboard-paste-image', +]); + +export function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} diff --git a/main/src/daemon/socketFraming.test.ts b/main/src/daemon/socketFraming.test.ts index c91c93fe..aed676a3 100644 --- a/main/src/daemon/socketFraming.test.ts +++ b/main/src/daemon/socketFraming.test.ts @@ -4,7 +4,6 @@ import { PaneDaemonFrameDecoder, } from './socketFraming'; import { - isDaemonOwnedChannel, isPaneDaemonEventFrame, isPaneDaemonFrame, isPaneDaemonRequestFrame, @@ -13,6 +12,7 @@ import { type PaneDaemonRequestFrame, type PaneDaemonResponseFrame, } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from './daemonChannels'; describe('Pane daemon framing', () => { it('encodes frames as newline-delimited JSON', () => { diff --git a/main/src/index.ts b/main/src/index.ts index f172056c..9189da99 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -952,11 +952,6 @@ async function createWindow() { resourceMonitorService.handleVisibilityChange(true); }); - // Pull-path query so the renderer can get the authoritative focus state on - // mount without waiting for the next focus-change event. Default to true - // (focused) if mainWindow is somehow null at call time. - ipcMain.handle('window:is-focused', () => mainWindow?.isFocused() ?? true); - mainWindow.on('restore', () => { // Don't assume restore = focused. The OS will fire 'focus' if/when the user // actually focuses the window; that is what restarts git/resource work. @@ -1212,6 +1207,10 @@ app.whenReady().then(async () => { await initializeServices(); console.log('[Main] Services initialized, creating window...'); + // Register before any renderer loads. useNotifications pulls this on mount + // and will race a late registration inside createWindow/loadURL. + ipcMain.handle('window:is-focused', () => mainWindow?.isFocused() ?? true); + // Start the ptyHost supervisor before the window opens so the renderer's // preload listener for 'ptyHost-port' has a port to receive when the window // finishes loading. Gated on the `usePtyHost` setting: when off (default), diff --git a/main/src/ipc/daemon.ts b/main/src/ipc/daemon.ts index d9e7f7dd..a28f7d35 100644 --- a/main/src/ipc/daemon.ts +++ b/main/src/ipc/daemon.ts @@ -1,4 +1,4 @@ -import { isDaemonOwnedChannel } from '../../../shared/types/daemon'; +import { isDaemonOwnedChannel } from '../daemon/daemonChannels'; import type { PaneCommandRegistry } from '../daemon/commandRegistry'; interface IpcMainHandleLike { diff --git a/main/src/preload.ts b/main/src/preload.ts index a7198ab4..fcde800b 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -3,7 +3,6 @@ import type { CreateSessionRequest, Session } from './types/session'; import type { AppConfig, UpdateConfigRequest } from './types/config'; import type { CreateProjectRequest, UpdateProjectRequest, Project } from '../../frontend/src/types/project'; import type { ToolPanel } from '../../shared/types/panels'; -import { isDaemonOwnedChannel } from '../../shared/types/daemon'; interface LogEntry { timestamp: string; @@ -88,6 +87,66 @@ interface UpdaterInfo { size?: number; } +const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ + 'file:showInFolder', + 'sessions:open-ide', + 'sessions:set-active-session', + 'terminal:clipboard-paste-image', +]); + +// Sandboxed Electron preload scripts cannot reliably require local runtime +// modules, so the daemon-owned channel classifier stays inline here. +function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + // Increase max listeners for ipcRenderer to prevent warnings when many components listen to events ipcRenderer.setMaxListeners(50); diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index fb6e7cc9..99b73499 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -46,64 +46,6 @@ interface PaneDaemonResponseFrameCandidate { error?: unknown; } -const DAEMON_OWNED_CHANNEL_PREFIXES = [ - 'folders:', - 'logs:', - 'panels:', - 'projects:', - 'prompts:', - 'resource-monitor:', - 'sessions:', - 'terminal:', -] as const; - -const DAEMON_OWNED_EXACT_CHANNELS = [ - 'git:cancel-status-for-project', - 'git:clone-repo', - 'git:commit', - 'git:execute-project', - 'git:file-status', - 'git:get-github-remote', - 'git:restore', - 'git:revert', - 'file:copy', - 'file:delete', - 'file:duplicate', - 'file:exists', - 'file:getPath', - 'file:list', - 'file:move', - 'file:read', - 'file:read-binary', - 'file:read-project', - 'file:readAtRevision', - 'file:rename', - 'file:resolveAbsolutePath', - 'file:search', - 'file:write', - 'file:write-binary', - 'file:write-project', -] as const; - -const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ - 'file:showInFolder', - 'sessions:open-ide', - 'sessions:set-active-session', - 'terminal:clipboard-paste-image', -]); - -export function isDaemonOwnedChannel(channel: string): boolean { - if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { - return false; - } - - if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { - return true; - } - - return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); -} - export function isPaneDaemonRequestFrame(frame: unknown): frame is PaneDaemonRequestFrame { if (typeof frame !== 'object' || frame === null) { return false; From f596c088f98c309423baa29c81a6653439593ab1 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 13:33:18 -0700 Subject: [PATCH 026/111] test: lock preload daemon channel parity --- main/src/core/importBoundary.test.ts | 21 ++++++++++ main/src/daemon/daemonChannels.ts | 63 +++------------------------- shared/types/daemon.ts | 60 ++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 57 deletions(-) diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 91284bab..cbf39df4 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import path from 'path'; import { describe, expect, it } from 'vitest'; +import { + DAEMON_OWNED_CHANNEL_PREFIXES, + DAEMON_OWNED_EXACT_CHANNELS, + ELECTRON_ADAPTER_ONLY_CHANNELS, +} from '../../../shared/types/daemon'; const MAIN_SRC_ROOT = path.resolve(process.cwd(), 'src'); @@ -73,4 +78,20 @@ describe('daemon/client import boundary', () => { /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, ); }); + + it('keeps preload channel ownership literals aligned with the shared daemon contract', () => { + const source = readMainSrcFile('preload.ts'); + + for (const prefix of DAEMON_OWNED_CHANNEL_PREFIXES) { + expect(source).toContain(`'${prefix}'`); + } + + for (const channel of DAEMON_OWNED_EXACT_CHANNELS) { + expect(source).toContain(`'${channel}'`); + } + + for (const channel of ELECTRON_ADAPTER_ONLY_CHANNELS) { + expect(source).toContain(`'${channel}'`); + } + }); }); diff --git a/main/src/daemon/daemonChannels.ts b/main/src/daemon/daemonChannels.ts index e13ae658..572c2214 100644 --- a/main/src/daemon/daemonChannels.ts +++ b/main/src/daemon/daemonChannels.ts @@ -1,57 +1,6 @@ -const DAEMON_OWNED_CHANNEL_PREFIXES = [ - 'folders:', - 'logs:', - 'panels:', - 'projects:', - 'prompts:', - 'resource-monitor:', - 'sessions:', - 'terminal:', -] as const; - -const DAEMON_OWNED_EXACT_CHANNELS = [ - 'git:cancel-status-for-project', - 'git:clone-repo', - 'git:commit', - 'git:execute-project', - 'git:file-status', - 'git:get-github-remote', - 'git:restore', - 'git:revert', - 'file:copy', - 'file:delete', - 'file:duplicate', - 'file:exists', - 'file:getPath', - 'file:list', - 'file:move', - 'file:read', - 'file:read-binary', - 'file:read-project', - 'file:readAtRevision', - 'file:rename', - 'file:resolveAbsolutePath', - 'file:search', - 'file:write', - 'file:write-binary', - 'file:write-project', -] as const; - -const ELECTRON_ADAPTER_ONLY_CHANNELS = new Set([ - 'file:showInFolder', - 'sessions:open-ide', - 'sessions:set-active-session', - 'terminal:clipboard-paste-image', -]); - -export function isDaemonOwnedChannel(channel: string): boolean { - if (ELECTRON_ADAPTER_ONLY_CHANNELS.has(channel)) { - return false; - } - - if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { - return true; - } - - return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); -} +export { + DAEMON_OWNED_CHANNEL_PREFIXES, + DAEMON_OWNED_EXACT_CHANNELS, + ELECTRON_ADAPTER_ONLY_CHANNELS, + isDaemonOwnedChannel, +} from '../../../shared/types/daemon'; diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index 99b73499..e91ca3ac 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -46,6 +46,66 @@ interface PaneDaemonResponseFrameCandidate { error?: unknown; } +export const DAEMON_OWNED_CHANNEL_PREFIXES = [ + 'folders:', + 'logs:', + 'panels:', + 'projects:', + 'prompts:', + 'resource-monitor:', + 'sessions:', + 'terminal:', +] as const; + +export const DAEMON_OWNED_EXACT_CHANNELS = [ + 'git:cancel-status-for-project', + 'git:clone-repo', + 'git:commit', + 'git:execute-project', + 'git:file-status', + 'git:get-github-remote', + 'git:restore', + 'git:revert', + 'file:copy', + 'file:delete', + 'file:duplicate', + 'file:exists', + 'file:getPath', + 'file:list', + 'file:move', + 'file:read', + 'file:read-binary', + 'file:read-project', + 'file:readAtRevision', + 'file:rename', + 'file:resolveAbsolutePath', + 'file:search', + 'file:write', + 'file:write-binary', + 'file:write-project', +] as const; + +export const ELECTRON_ADAPTER_ONLY_CHANNELS = [ + 'file:showInFolder', + 'sessions:open-ide', + 'sessions:set-active-session', + 'terminal:clipboard-paste-image', +] as const; + +const ELECTRON_ADAPTER_ONLY_CHANNEL_SET = new Set(ELECTRON_ADAPTER_ONLY_CHANNELS); + +export function isDaemonOwnedChannel(channel: string): boolean { + if (ELECTRON_ADAPTER_ONLY_CHANNEL_SET.has(channel)) { + return false; + } + + if (DAEMON_OWNED_EXACT_CHANNELS.includes(channel as (typeof DAEMON_OWNED_EXACT_CHANNELS)[number])) { + return true; + } + + return DAEMON_OWNED_CHANNEL_PREFIXES.some((prefix) => channel.startsWith(prefix)); +} + export function isPaneDaemonRequestFrame(frame: unknown): frame is PaneDaemonRequestFrame { if (typeof frame !== 'object' || frame === null) { return false; From 39a22bb2433a88753a0d09863e41329899fb0983 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 13:51:16 -0700 Subject: [PATCH 027/111] feat: extract headless daemon host bootstrap --- main/package.json | 1 + main/src/core/importBoundary.test.ts | 21 + main/src/daemon/bootstrap.ts | 266 ++++++++++++ main/src/daemon/headless.ts | 61 +++ main/src/index.ts | 381 +++--------------- main/src/ipc/panels.ts | 4 +- main/src/ipc/types.ts | 7 +- .../services/resourceMonitorService.test.ts | 11 + main/src/services/resourceMonitorService.ts | 15 +- main/src/services/taskQueue.ts | 9 +- main/src/utils/appDirectory.test.ts | 18 + main/src/utils/appDirectory.ts | 52 ++- package.json | 1 + 13 files changed, 497 insertions(+), 350 deletions(-) create mode 100644 main/src/daemon/bootstrap.ts create mode 100644 main/src/daemon/headless.ts create mode 100644 main/src/services/resourceMonitorService.test.ts create mode 100644 main/src/utils/appDirectory.test.ts diff --git a/main/package.json b/main/package.json index 63209ea0..5c1e4c57 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 cbf39df4..47cf47a1 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 00000000..b22d7626 --- /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 00000000..b88a451b --- /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 9189da99..b3aa7cdc 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 1daa4e0b..302d4654 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 196a1887..5d1b4bea 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 00000000..958623a2 --- /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 86421993..b27d4bd3 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 1a4a1c4c..28d5014a 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 00000000..ebd0ee0a --- /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 0889a0ef..ed5ec8cd 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 2cc2a7c8..3c09c809 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", From 41a301699ac8dc76cf73da504859096a1537eb2e Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 14:07:06 -0700 Subject: [PATCH 028/111] feat: add remote daemon config surface --- frontend/src/components/Settings.tsx | 22 ++- frontend/src/types/config.ts | 91 ++++++++--- frontend/src/types/electron.d.ts | 22 ++- frontend/src/utils/api.ts | 46 +++++- main/src/ipc/config.ts | 7 +- main/src/ipc/index.ts | 2 + main/src/ipc/remoteDaemon.test.ts | 169 ++++++++++++++++++++ main/src/ipc/remoteDaemon.ts | 221 +++++++++++++++++++++++++++ main/src/preload.ts | 25 ++- main/src/services/configManager.ts | 11 +- main/src/types/config.ts | 13 +- shared/types/remoteDaemon.ts | 165 ++++++++++++++++++++ 12 files changed, 757 insertions(+), 37 deletions(-) create mode 100644 main/src/ipc/remoteDaemon.test.ts create mode 100644 main/src/ipc/remoteDaemon.ts create mode 100644 shared/types/remoteDaemon.ts diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 0fcc8ae9..4e0b529a 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { NotificationSettings } from './NotificationSettings'; import { useNotifications } from '../hooks/useNotifications'; import { API } from '../utils/api'; import { optIn, capture, captureAndOptOut } from '../services/posthog'; -import type { AppConfig, TerminalShortcut } from '../types/config'; +import type { PreferredShell, TerminalShortcut } from '../types/config'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktreeFileSync'; import { useConfigStore } from '../stores/configStore'; @@ -46,8 +46,13 @@ interface SettingsProps { initialSection?: string; } +type AvailableShell = { + id: PreferredShell; + name: string; + path: string; +}; + export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { - const [_config, setConfig] = useState(null); const [verbose, setVerbose] = useState(false); const [claudeExecutablePath, setClaudeExecutablePath] = useState(''); const [autoCheckUpdates, setAutoCheckUpdates] = useState(true); @@ -75,8 +80,8 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const [activeTab, setActiveTab] = useState<'general' | 'notifications' | 'shortcuts'>('general'); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [previousAnalyticsEnabled, setPreviousAnalyticsEnabled] = useState(true); - const [preferredShell, setPreferredShell] = useState('auto'); - const [availableShells, setAvailableShells] = useState>([]); + const [preferredShell, setPreferredShell] = useState('auto'); + const [availableShells, setAvailableShells] = useState([]); const [terminalShortcuts, setTerminalShortcuts] = useState([]); const [worktreeFileSync, setWorktreeFileSync] = useState([]); const { updateSettings } = useNotifications(); @@ -138,9 +143,10 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const fetchConfig = async (currentPlatform?: string) => { try { const response = await API.config.get(); - if (!response.success) throw new Error(response.error || 'Failed to fetch config'); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to fetch config'); + } const data = response.data; - setConfig(data); setVerbose(data.verbose || false); setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true setDevMode(data.devMode || false); @@ -174,7 +180,7 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const platformToCheck = currentPlatform || platform; if (platformToCheck === 'win32') { const shellsResponse = await API.config.getAvailableShells(); - if (shellsResponse.success) { + if (shellsResponse.success && shellsResponse.data) { setAvailableShells(shellsResponse.data); } } @@ -185,7 +191,7 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { // Load worktree file sync entries setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); - } catch (err) { + } catch { setError('Failed to load configuration'); } }; diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 9b83a61c..7eb9e3c4 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -1,4 +1,5 @@ import type { CloudVmConfig } from '../../../shared/types/cloud'; +import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; export interface TerminalShortcut { @@ -24,25 +25,52 @@ export interface AnalyticsIdentity { gitUserName?: string; } +export interface AnalyticsConfig { + enabled: boolean; + posthogApiKey?: string; + posthogHost?: string; + distinctId?: string; + identitySource?: AnalyticsIdentity['identitySource']; + githubUsername?: string; + githubEmail?: string; + gitEmail?: string; + gitEmailHash?: string; + gitUserName?: string; +} + export interface AppConfig { - gitRepoPath: string; verbose?: boolean; anthropicApiKey?: string; + openaiApiKey?: string; + // Legacy fields for backward compatibility + gitRepoPath?: string; systemPromptAppend?: string; runScript?: string[]; + // Custom claude executable path (for when it's not in PATH) claudeExecutablePath?: string; + // Permission mode for all sessions defaultPermissionMode?: 'approve' | 'ignore'; + // Default model for new sessions + defaultModel?: string; + // Auto-check for updates autoCheckUpdates?: boolean; + // Stravu MCP integration + stravuApiKey?: string; + stravuServerUrl?: string; + // Theme preference theme?: 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta'; + // UI scale factor (0.75 to 1.5, default 1.0) uiScale?: number; + // Notification settings notifications?: { playSound: boolean; enabled: boolean; }; + // Dev mode for debugging devMode?: boolean; - // Route PTY spawns through an isolated ptyHost UtilityProcess for crash - // isolation. Off by default. Requires app restart to take effect. - usePtyHost?: boolean; + // Additional paths to add to PATH environment variable + additionalPaths?: string[]; + // Session creation preferences sessionCreationPreferences?: { sessionCount?: number; toolType?: 'claude' | 'none'; @@ -59,29 +87,58 @@ export interface AppConfig { }; // Pane commit footer setting (enabled by default) enableCommitFooter?: boolean; + // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) + useInteractiveMode?: boolean; + // Route PTY spawns through an isolated ptyHost UtilityProcess for crash + // isolation. Off by default. Requires app restart; the supervisor is forked + // once at `app.whenReady`. + usePtyHost?: boolean; // PostHog analytics settings - analytics?: { - enabled: boolean; - posthogApiKey?: string; - posthogHost?: string; - distinctId?: string; - identitySource?: AnalyticsIdentity['identitySource']; - githubUsername?: string; - githubEmail?: string; - gitEmail?: string; - gitEmailHash?: string; - gitUserName?: string; - }; + analytics?: AnalyticsConfig; // User-defined custom commands for the Add Tool picker customCommands?: CustomCommand[]; // Terminal shortcuts — hotkey-triggered clipboard paste snippets terminalShortcuts?: TerminalShortcut[]; // Worktree file sync — files/dirs to copy from main repo into new worktrees worktreeFileSync?: WorktreeFileSyncEntry[]; - // Preferred shell for terminal sessions on Windows + // Preferred shell for Windows terminals preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; + terminalFontFamily?: string; + terminalFontSize?: number; +} + +export type PreferredShell = NonNullable; + +export interface UpdateConfigRequest { + verbose?: boolean; + anthropicApiKey?: string; + openaiApiKey?: string; + claudeExecutablePath?: string; + systemPromptAppend?: string; + defaultPermissionMode?: 'approve' | 'ignore'; + defaultModel?: string; + autoCheckUpdates?: boolean; + stravuApiKey?: string; + stravuServerUrl?: string; + theme?: AppConfig['theme']; + uiScale?: number; + notifications?: AppConfig['notifications']; + devMode?: boolean; + additionalPaths?: string[]; + sessionCreationPreferences?: AppConfig['sessionCreationPreferences']; + enableCommitFooter?: boolean; + useInteractiveMode?: boolean; + usePtyHost?: boolean; + analytics?: AnalyticsConfig; + customCommands?: CustomCommand[]; + terminalShortcuts?: TerminalShortcut[]; + worktreeFileSync?: WorktreeFileSyncEntry[]; + preferredShell?: PreferredShell; + cloud?: CloudVmConfig; terminalFontFamily?: string; terminalFontSize?: number; } diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 11f81d0c..bf4b4a37 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -2,7 +2,15 @@ import type { Session, SessionOutput, GitStatus, VersionUpdateInfo } from './session'; import type { Project } from './project'; import type { Folder } from './folder'; +import type { AppConfig, UpdateConfigRequest } from './config'; import type { SessionCreationPreferences } from '../stores/sessionPreferencesStore'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonConfig, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../../shared/types/remoteDaemon'; import type { ToolPanel } from '../../../shared/types/panels'; import type { CreateSessionRequest } from './session'; import type { DetectedProjectConfig } from '../../../shared/types/projectConfig'; @@ -208,14 +216,24 @@ interface ElectronAPI { // Configuration config: { - get: () => Promise; - update: (updates: Record) => Promise; + get: () => Promise>; + update: (updates: UpdateConfigRequest) => Promise; getSessionPreferences: () => Promise; updateSessionPreferences: (preferences: SessionCreationPreferences) => Promise; getAvailableShells: () => Promise; getMonospaceFonts: () => Promise; }; + remoteDaemon: { + getConfig: () => Promise>; + updateHostConfig: (updates: Partial) => Promise>; + upsertClientRecord: (record: RemoteDaemonClientRecord) => Promise>; + deleteClientRecord: (clientId: string) => Promise>; + upsertConnectionProfile: (profile: RemotePaneConnectionProfile) => Promise>; + deleteConnectionProfile: (profileId: string) => Promise>; + updateClientState: (updates: Partial>) => Promise>; + }; + // Prompts prompts: { getAll: () => Promise; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index b531d1a2..0406dfc7 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,7 +1,14 @@ // Utility for making API calls using Electron IPC import type { CreateSessionRequest } from '../types/session'; import type { Project } from '../types/project'; +import type { UpdateConfigRequest } from '../types/config'; import type { SessionCreationPreferences } from '../stores/sessionPreferencesStore'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../../shared/types/remoteDaemon'; // Type for IPC response // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic type parameter default for flexible API responses @@ -443,7 +450,7 @@ export class API { return window.electronAPI.config.get(); }, - async update(updates: Record) { + async update(updates: UpdateConfigRequest) { if (!isElectron()) throw new Error('Electron API not available'); return window.electronAPI.config.update(updates); }, @@ -464,6 +471,43 @@ export class API { }, }; + static remoteDaemon = { + async getConfig() { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.getConfig(); + }, + + async updateHostConfig(updates: Partial) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.updateHostConfig(updates); + }, + + async upsertClientRecord(record: RemoteDaemonClientRecord) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.upsertClientRecord(record); + }, + + async deleteClientRecord(clientId: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.deleteClientRecord(clientId); + }, + + async upsertConnectionProfile(profile: RemotePaneConnectionProfile) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.upsertConnectionProfile(profile); + }, + + async deleteConnectionProfile(profileId: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.deleteConnectionProfile(profileId); + }, + + async updateClientState(updates: Partial>) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.updateClientState(updates); + }, + }; + // Prompts static prompts = { async getAll() { diff --git a/main/src/ipc/config.ts b/main/src/ipc/config.ts index fdf21435..d842b682 100644 --- a/main/src/ipc/config.ts +++ b/main/src/ipc/config.ts @@ -1,10 +1,11 @@ import { IpcMain } from 'electron'; import { execFile } from 'child_process'; import type { AppServices } from './types'; +import type { AppConfig, UpdateConfigRequest } from '../types/config'; import { ShellDetector } from '../utils/shellDetector'; export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claudeCodeManager, getMainWindow }: AppServices): void { - ipcMain.handle('config:get', async () => { + ipcMain.handle('config:get', async (): Promise<{ success: boolean; data?: AppConfig; error?: string }> => { try { // Always reload from disk to pick up external changes (e.g., from setup scripts) const config = await configManager.reloadFromDisk(); @@ -15,7 +16,7 @@ export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claude } }); - ipcMain.handle('config:update', async (_event, updates: import('../types/config').UpdateConfigRequest) => { + ipcMain.handle('config:update', async (_event, updates: UpdateConfigRequest) => { try { // Check if Claude path is being updated const oldConfig = configManager.getConfig(); @@ -165,4 +166,4 @@ export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claude return { success: false, data: [] }; } }); -} \ No newline at end of file +} diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index f66ae190..4d562cfb 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -19,6 +19,7 @@ import { registerEditorPanelHandlers } from './editorPanel'; import { registerNimbalystHandlers } from './nimbalyst'; import { registerSpotlightHandlers } from './spotlight'; import { registerCloudHandlers } from './cloud'; +import { registerRemoteDaemonHandlers } from './remoteDaemon'; import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; @@ -48,6 +49,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerNimbalystHandlers(ipcMain, services); registerSpotlightHandlers(ipcMain, services); registerCloudHandlers(ipcMain, services); + registerRemoteDaemonHandlers(ipcMain, services); registerClipboardHandlers(ipcMain, services); registerResourceMonitorHandlers(ipcMain, services, commandRegistry); registerOnboardingHandlers(ipcMain, services); diff --git a/main/src/ipc/remoteDaemon.test.ts b/main/src/ipc/remoteDaemon.test.ts new file mode 100644 index 00000000..49a6d00a --- /dev/null +++ b/main/src/ipc/remoteDaemon.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultRemoteDaemonConfig, type RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import { registerRemoteDaemonHandlers } from './remoteDaemon'; + +interface IpcMainStub { + handlers: Map Promise>; + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +interface ConfigManagerStub { + getConfig(): { remoteDaemon?: RemoteDaemonConfig }; + updateConfig(updates: { remoteDaemon?: RemoteDaemonConfig }): Promise<{ remoteDaemon?: RemoteDaemonConfig }>; +} + +function createIpcMainStub(): IpcMainStub { + const handlers = new Map Promise>(); + + return { + handlers, + handle(channel, listener) { + handlers.set(channel, listener); + }, + }; +} + +function createConfigManagerStub(initialConfig?: RemoteDaemonConfig): ConfigManagerStub { + let remoteDaemon = initialConfig; + + return { + getConfig() { + return { remoteDaemon }; + }, + async updateConfig(updates) { + remoteDaemon = updates.remoteDaemon; + return { remoteDaemon }; + }, + }; +} + +describe('remote daemon IPC', () => { + it('returns normalized remote daemon defaults when config is missing', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + await expect(ipcMain.handlers.get('remote-daemon:get-config')?.({})).resolves.toEqual({ + success: true, + data: createDefaultRemoteDaemonConfig(), + }); + }); + + it('persists connection profiles and client state through dedicated handlers', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const upsertProfile = ipcMain.handlers.get('remote-daemon:upsert-connection-profile'); + const updateClientState = ipcMain.handlers.get('remote-daemon:update-client-state'); + const getConfig = ipcMain.handlers.get('remote-daemon:get-config'); + + await expect(upsertProfile?.({}, { + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + })).resolves.toEqual({ + success: true, + data: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + }); + + await expect(updateClientState?.({}, { + activeProfileId: 'profile-1', + mode: 'remote', + })).resolves.toEqual({ + success: true, + data: { + profiles: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }, + }); + + await expect(getConfig?.({})).resolves.toEqual({ + success: true, + data: { + host: { + config: createDefaultRemoteDaemonConfig().host.config, + clients: [], + }, + client: { + profiles: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }, + }, + }); + }); + + it('falls back to local mode when deleting the active connection profile', async () => { + const initialConfig = createDefaultRemoteDaemonConfig(); + initialConfig.client = { + profiles: [{ + id: 'profile-1', + label: 'Workstation', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }; + + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(initialConfig); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const deleteProfile = ipcMain.handlers.get('remote-daemon:delete-connection-profile'); + + await expect(deleteProfile?.({}, 'profile-1')).resolves.toEqual({ + success: true, + data: { + profiles: [], + activeProfileId: null, + mode: 'local', + }, + }); + }); + + it('normalizes stale remote mode back to local when no active profile remains', async () => { + const initialConfig = createDefaultRemoteDaemonConfig(); + initialConfig.client = { + profiles: [], + activeProfileId: null, + mode: 'remote', + }; + + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(initialConfig); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + await expect(ipcMain.handlers.get('remote-daemon:get-config')?.({})).resolves.toEqual({ + success: true, + data: createDefaultRemoteDaemonConfig(), + }); + }); +}); diff --git a/main/src/ipc/remoteDaemon.ts b/main/src/ipc/remoteDaemon.ts new file mode 100644 index 00000000..d32712e7 --- /dev/null +++ b/main/src/ipc/remoteDaemon.ts @@ -0,0 +1,221 @@ +import { + isRemoteDaemonClientRecord, + isRemotePaneConnectionProfile, + normalizeRemoteDaemonConfig, + type RemoteDaemonClientMode, + type RemoteDaemonClientSettings, + type RemoteDaemonConfig, +} from '../../../shared/types/remoteDaemon'; +import type { AppServices } from './types'; + +interface IpcMainHandleLike { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +export function registerRemoteDaemonHandlers( + ipcMain: IpcMainHandleLike, + { configManager }: Pick, +): void { + ipcMain.handle('remote-daemon:get-config', async () => { + try { + return { success: true, data: getRemoteDaemonConfig(configManager.getConfig().remoteDaemon) }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to get remote daemon config') }; + } + }); + + ipcMain.handle('remote-daemon:update-host-config', async (_event, updates: unknown) => { + try { + if (!isRecord(updates)) { + throw new Error('Remote daemon host config update must be an object'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + config: { + ...current.host.config, + ...updates, + }, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.config }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to update remote daemon host config') }; + } + }); + + ipcMain.handle('remote-daemon:upsert-client-record', async (_event, record: unknown) => { + try { + if (!isRemoteDaemonClientRecord(record)) { + throw new Error('Remote daemon client record is invalid'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const clients = upsertById(current.host.clients, record); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + clients, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.clients }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to save remote daemon client record') }; + } + }); + + ipcMain.handle('remote-daemon:delete-client-record', async (_event, clientId: unknown) => { + try { + if (typeof clientId !== 'string' || clientId.length === 0) { + throw new Error('Remote daemon client record id must be a non-empty string'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + clients: current.host.clients.filter((client) => client.id !== clientId), + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.clients }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to delete remote daemon client record') }; + } + }); + + ipcMain.handle('remote-daemon:upsert-connection-profile', async (_event, profile: unknown) => { + try { + if (!isRemotePaneConnectionProfile(profile)) { + throw new Error('Remote daemon connection profile is invalid'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const profiles = upsertById(current.client.profiles, profile); + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + profiles, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client.profiles }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to save remote daemon connection profile') }; + } + }); + + ipcMain.handle('remote-daemon:delete-connection-profile', async (_event, profileId: unknown) => { + try { + if (typeof profileId !== 'string' || profileId.length === 0) { + throw new Error('Remote daemon connection profile id must be a non-empty string'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const activeProfileId = current.client.activeProfileId === profileId + ? null + : current.client.activeProfileId; + const mode = activeProfileId ? current.client.mode : 'local'; + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + profiles: current.client.profiles.filter((profile) => profile.id !== profileId), + activeProfileId, + mode, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to delete remote daemon connection profile') }; + } + }); + + ipcMain.handle('remote-daemon:update-client-state', async (_event, updates: unknown) => { + try { + if (!isRecord(updates)) { + throw new Error('Remote daemon client state update must be an object'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const nextState = buildNextClientState(current.client, updates); + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + ...nextState, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to update remote daemon client state') }; + } + }); +} + +function getRemoteDaemonConfig(value: unknown): RemoteDaemonConfig { + return normalizeRemoteDaemonConfig(value); +} + +function buildNextClientState( + current: RemoteDaemonClientSettings, + updates: Record, +): Pick { + const nextMode: RemoteDaemonClientMode = + updates.mode === 'remote' || updates.mode === 'local' + ? updates.mode + : current.mode; + + let nextActiveProfileId = current.activeProfileId; + if (updates.activeProfileId === null) { + nextActiveProfileId = null; + } else if (typeof updates.activeProfileId === 'string') { + nextActiveProfileId = updates.activeProfileId; + } + + if (nextMode === 'remote' && !nextActiveProfileId) { + throw new Error('Remote mode requires an active connection profile'); + } + + if (nextActiveProfileId && !current.profiles.some((profile) => profile.id === nextActiveProfileId)) { + throw new Error(`Remote daemon connection profile "${nextActiveProfileId}" does not exist`); + } + + return { + mode: nextActiveProfileId ? nextMode : 'local', + activeProfileId: nextActiveProfileId, + }; +} + +function upsertById(items: T[], nextItem: T): T[] { + const existingIndex = items.findIndex((item) => item.id === nextItem.id); + if (existingIndex === -1) { + return [...items, nextItem]; + } + + return items.map((item, index) => (index === existingIndex ? nextItem : item)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/main/src/preload.ts b/main/src/preload.ts index fcde800b..41c091aa 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -2,6 +2,13 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { CreateSessionRequest, Session } from './types/session'; import type { AppConfig, UpdateConfigRequest } from './types/config'; import type { CreateProjectRequest, UpdateProjectRequest, Project } from '../../frontend/src/types/project'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonConfig, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../shared/types/remoteDaemon'; import type { ToolPanel } from '../../shared/types/panels'; interface LogEntry { @@ -556,7 +563,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Configuration config: { - get: (): Promise => invokeIpc('config:get'), + get: (): Promise> => invokeIpc('config:get'), update: (updates: UpdateConfigRequest): Promise => invokeIpc('config:update', updates), getSessionPreferences: (): Promise => invokeIpc('config:get-session-preferences'), updateSessionPreferences: (preferences: AppConfig['sessionCreationPreferences']): Promise => invokeIpc('config:update-session-preferences', preferences), @@ -564,6 +571,22 @@ contextBridge.exposeInMainWorld('electronAPI', { getMonospaceFonts: (): Promise => invokeIpc('config:get-monospace-fonts'), }, + remoteDaemon: { + getConfig: (): Promise> => invokeIpc('remote-daemon:get-config'), + updateHostConfig: (updates: Partial): Promise> => + invokeIpc('remote-daemon:update-host-config', updates), + upsertClientRecord: (record: RemoteDaemonClientRecord): Promise> => + invokeIpc('remote-daemon:upsert-client-record', record), + deleteClientRecord: (clientId: string): Promise> => + invokeIpc('remote-daemon:delete-client-record', clientId), + upsertConnectionProfile: (profile: RemotePaneConnectionProfile): Promise> => + invokeIpc('remote-daemon:upsert-connection-profile', profile), + deleteConnectionProfile: (profileId: string): Promise> => + invokeIpc('remote-daemon:delete-connection-profile', profileId), + updateClientState: (updates: Partial>): Promise> => + invokeIpc('remote-daemon:update-client-state', updates), + }, + // Prompts prompts: { getAll: (): Promise => invokeIpc('prompts:get-all'), diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 705f9522..7b0a54b5 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import type { AnalyticsIdentity, AppConfig } from '../types/config'; +import { createDefaultRemoteDaemonConfig, normalizeRemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktreeFileSync'; import fs from 'fs/promises'; @@ -56,6 +57,7 @@ export class ConfigManager extends EventEmitter { posthogApiKey: 'phc_wir25CCsjr2NsZGEdlWNdvwcNG1XDjhxc9RyL5KDCf1', posthogHost: 'https://us.i.posthog.com' }, + remoteDaemon: createDefaultRemoteDaemonConfig(), terminalShortcuts: [ { id: 'default-root-cause', @@ -133,6 +135,7 @@ export class ConfigManager extends EventEmitter { ...this.config.analytics, ...loadedConfig.analytics }, + remoteDaemon: normalizeRemoteDaemonConfig(loadedConfig.remoteDaemon), // Use !== undefined to distinguish "user cleared all entries" (empty array → preserve) // from "field absent in config file" (→ use defaults) worktreeFileSync: loadedConfig.worktreeFileSync !== undefined @@ -256,7 +259,13 @@ export class ConfigManager extends EventEmitter { } async updateConfig(updates: Partial): Promise { - this.config = { ...this.config, ...updates }; + this.config = { + ...this.config, + ...updates, + remoteDaemon: 'remoteDaemon' in updates + ? normalizeRemoteDaemonConfig(updates.remoteDaemon) + : this.config.remoteDaemon, + }; await this.saveConfig(); // Clear PATH cache if additional paths were updated diff --git a/main/src/types/config.ts b/main/src/types/config.ts index 774506fb..02ea6214 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -1,3 +1,7 @@ +import type { CloudVmConfig } from '../../../shared/types/cloud'; +import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; + export interface TerminalShortcut { id: string; label: string; @@ -34,9 +38,6 @@ export interface AnalyticsConfig { gitUserName?: string; } -import type { CloudVmConfig } from '../../../shared/types/cloud'; -import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; - export interface AppConfig { verbose?: boolean; anthropicApiKey?: string; @@ -103,6 +104,8 @@ export interface AppConfig { preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; terminalFontFamily?: string; terminalFontSize?: number; } @@ -140,7 +143,7 @@ export interface UpdateConfigRequest { showAdvanced?: boolean; baseBranch?: string; }; - disableCommitFooter?: boolean; + enableCommitFooter?: boolean; // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) useInteractiveMode?: boolean; // Route PTY spawns through an isolated ptyHost UtilityProcess for crash isolation. @@ -158,6 +161,8 @@ export interface UpdateConfigRequest { preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; terminalFontFamily?: string; terminalFontSize?: number; } diff --git a/shared/types/remoteDaemon.ts b/shared/types/remoteDaemon.ts new file mode 100644 index 00000000..2bb9494f --- /dev/null +++ b/shared/types/remoteDaemon.ts @@ -0,0 +1,165 @@ +export type RemoteDaemonTransport = 'http+sse'; +export type RemoteDaemonClientMode = 'local' | 'remote'; + +export interface RemoteDaemonHostConfig { + enabled: boolean; + listenHost: string; + listenPort: number; + pairingRequired: boolean; + allowInsecureHttpOnLoopback: boolean; +} + +export interface RemoteDaemonClientRecord { + id: string; + label: string; + createdAt: string; + tokenHash: string; + lastUsedAt?: string; +} + +export interface RemotePaneConnectionProfile { + id: string; + label: string; + baseUrl: string; + token: string; + transport: RemoteDaemonTransport; +} + +export interface RemoteDaemonHostSettings { + config: RemoteDaemonHostConfig; + clients: RemoteDaemonClientRecord[]; +} + +export interface RemoteDaemonClientSettings { + profiles: RemotePaneConnectionProfile[]; + activeProfileId: string | null; + mode: RemoteDaemonClientMode; +} + +export interface RemoteDaemonConfig { + host: RemoteDaemonHostSettings; + client: RemoteDaemonClientSettings; +} + +export interface RemoteInvokeRequest { + channel: string; + args: unknown[]; +} + +export interface RemoteDaemonEventEnvelope { + channel: string; + args: unknown[]; + timestamp: string; +} + +export const DEFAULT_REMOTE_DAEMON_HOST_CONFIG: RemoteDaemonHostConfig = { + enabled: false, + listenHost: '127.0.0.1', + listenPort: 42137, + pairingRequired: true, + allowInsecureHttpOnLoopback: true, +}; + +export function createDefaultRemoteDaemonConfig(): RemoteDaemonConfig { + return { + host: { + config: { ...DEFAULT_REMOTE_DAEMON_HOST_CONFIG }, + clients: [], + }, + client: { + profiles: [], + activeProfileId: null, + mode: 'local', + }, + }; +} + +export function isRemoteDaemonClientRecord(value: unknown): value is RemoteDaemonClientRecord { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.label === 'string' && + typeof value.createdAt === 'string' && + typeof value.tokenHash === 'string' && + (value.lastUsedAt === undefined || typeof value.lastUsedAt === 'string') + ); +} + +export function isRemotePaneConnectionProfile(value: unknown): value is RemotePaneConnectionProfile { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.label === 'string' && + typeof value.baseUrl === 'string' && + typeof value.token === 'string' && + value.transport === 'http+sse' + ); +} + +export function normalizeRemoteDaemonConfig(value: unknown): RemoteDaemonConfig { + const defaults = createDefaultRemoteDaemonConfig(); + if (!isRecord(value)) { + return defaults; + } + + const host = isRecord(value.host) ? value.host : {}; + const hostConfig = isRecord(host.config) ? host.config : {}; + const clients = Array.isArray(host.clients) + ? host.clients.filter(isRemoteDaemonClientRecord) + : []; + + const client = isRecord(value.client) ? value.client : {}; + const profiles = Array.isArray(client.profiles) + ? client.profiles.filter(isRemotePaneConnectionProfile) + : []; + + let activeProfileId = typeof client.activeProfileId === 'string' ? client.activeProfileId : null; + if (activeProfileId && !profiles.some((profile) => profile.id === activeProfileId)) { + activeProfileId = null; + } + + return { + host: { + config: { + enabled: readBoolean(hostConfig.enabled, defaults.host.config.enabled), + listenHost: readString(hostConfig.listenHost, defaults.host.config.listenHost), + listenPort: readPort(hostConfig.listenPort, defaults.host.config.listenPort), + pairingRequired: readBoolean(hostConfig.pairingRequired, defaults.host.config.pairingRequired), + allowInsecureHttpOnLoopback: readBoolean( + hostConfig.allowInsecureHttpOnLoopback, + defaults.host.config.allowInsecureHttpOnLoopback, + ), + }, + clients: [...clients], + }, + client: { + profiles: [...profiles], + activeProfileId, + mode: activeProfileId && client.mode === 'remote' ? 'remote' : 'local', + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function readString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim().length > 0 ? value : fallback; +} + +function readPort(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535 + ? value + : fallback; +} From f505891758cd34621438fd3bdcf465b69871c740 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 14:09:54 -0700 Subject: [PATCH 029/111] fix: reject empty remote daemon profiles --- main/src/ipc/remoteDaemon.test.ts | 20 ++++++++++++++++++++ shared/types/remoteDaemon.ts | 22 +++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/main/src/ipc/remoteDaemon.test.ts b/main/src/ipc/remoteDaemon.test.ts index 49a6d00a..e7c332c4 100644 --- a/main/src/ipc/remoteDaemon.test.ts +++ b/main/src/ipc/remoteDaemon.test.ts @@ -166,4 +166,24 @@ describe('remote daemon IPC', () => { data: createDefaultRemoteDaemonConfig(), }); }); + + it('rejects connection profiles with empty auth or endpoint fields', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const upsertProfile = ipcMain.handlers.get('remote-daemon:upsert-connection-profile'); + + await expect(upsertProfile?.({}, { + id: 'profile-1', + label: 'Broken profile', + baseUrl: ' ', + token: '', + transport: 'http+sse', + })).resolves.toEqual({ + success: false, + error: 'Remote daemon connection profile is invalid', + }); + }); }); diff --git a/shared/types/remoteDaemon.ts b/shared/types/remoteDaemon.ts index 2bb9494f..89e0a3cc 100644 --- a/shared/types/remoteDaemon.ts +++ b/shared/types/remoteDaemon.ts @@ -80,11 +80,11 @@ export function isRemoteDaemonClientRecord(value: unknown): value is RemoteDaemo } return ( - typeof value.id === 'string' && - typeof value.label === 'string' && - typeof value.createdAt === 'string' && - typeof value.tokenHash === 'string' && - (value.lastUsedAt === undefined || typeof value.lastUsedAt === 'string') + isNonEmptyString(value.id) && + isNonEmptyString(value.label) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.tokenHash) && + (value.lastUsedAt === undefined || isNonEmptyString(value.lastUsedAt)) ); } @@ -94,10 +94,10 @@ export function isRemotePaneConnectionProfile(value: unknown): value is RemotePa } return ( - typeof value.id === 'string' && - typeof value.label === 'string' && - typeof value.baseUrl === 'string' && - typeof value.token === 'string' && + isNonEmptyString(value.id) && + isNonEmptyString(value.label) && + isNonEmptyString(value.baseUrl) && + isNonEmptyString(value.token) && value.transport === 'http+sse' ); } @@ -163,3 +163,7 @@ function readPort(value: unknown, fallback: number): number { ? value : fallback; } + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} From 150c178115fa04f63a97d6a76beb48dbe268f26c Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:06:50 -0700 Subject: [PATCH 030/111] feat: add remote daemon HTTP transport Add authenticated HTTP invoke and SSE event transport above the daemon registry. Keep daemon runtime event fanout shared across local and remote transports. --- main/src/core/runtime.ts | 5 + main/src/daemon/auth.test.ts | 59 ++++ main/src/daemon/auth.ts | 92 ++++++ main/src/daemon/bootstrap.ts | 40 ++- main/src/daemon/httpApiServer.test.ts | 341 ++++++++++++++++++++ main/src/daemon/httpApiServer.ts | 445 ++++++++++++++++++++++++++ main/src/daemon/server.ts | 2 +- 7 files changed, 976 insertions(+), 8 deletions(-) create mode 100644 main/src/daemon/auth.test.ts create mode 100644 main/src/daemon/auth.ts create mode 100644 main/src/daemon/httpApiServer.test.ts create mode 100644 main/src/daemon/httpApiServer.ts diff --git a/main/src/core/runtime.ts b/main/src/core/runtime.ts index 68052130..7ea04116 100644 --- a/main/src/core/runtime.ts +++ b/main/src/core/runtime.ts @@ -46,6 +46,11 @@ export interface PtyHostRuntime { */ export interface PaneRuntime { eventSink: PaneEventSink; + /** + * Non-renderer daemon subscribers, such as the local socket server and the + * remote HTTP/SSE transport. This stream is live-only; reconnecting clients + * are expected to refetch state instead of relying on server-side replay. + */ daemonEventSink?: PaneEventSink; getConfigManager(): ConfigManager; getPtyHostRuntime(): PtyHostRuntime | null; diff --git a/main/src/daemon/auth.test.ts b/main/src/daemon/auth.test.ts new file mode 100644 index 00000000..8dcf7e46 --- /dev/null +++ b/main/src/daemon/auth.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import type { RemoteDaemonClientRecord } from '../../../shared/types/remoteDaemon'; +import { authenticateRemoteDaemonBearerToken, hashRemoteDaemonToken } from './auth'; + +function createClientRecord(id: string, token: string): RemoteDaemonClientRecord { + return { + id, + label: `Client ${id}`, + createdAt: new Date('2026-05-14T00:00:00.000Z').toISOString(), + tokenHash: hashRemoteDaemonToken(token), + }; +} + +describe('remote daemon auth', () => { + it('authenticates matching bearer tokens against paired clients', () => { + const result = authenticateRemoteDaemonBearerToken( + 'Bearer secret-token', + [createClientRecord('client-1', 'secret-token')], + ); + + expect(result).toEqual({ + ok: true, + client: createClientRecord('client-1', 'secret-token'), + }); + }); + + it('rejects missing bearer tokens', () => { + expect(authenticateRemoteDaemonBearerToken(undefined, [createClientRecord('client-1', 'secret-token')])).toEqual({ + ok: false, + statusCode: 401, + error: { + message: 'Remote daemon bearer token is required', + code: 'ERR_REMOTE_DAEMON_AUTH_REQUIRED', + }, + }); + }); + + it('rejects invalid bearer tokens', () => { + expect(authenticateRemoteDaemonBearerToken('Bearer wrong-token', [createClientRecord('client-1', 'secret-token')])).toEqual({ + ok: false, + statusCode: 403, + error: { + message: 'Remote daemon bearer token is invalid', + code: 'ERR_REMOTE_DAEMON_AUTH_INVALID', + }, + }); + }); + + it('rejects malformed authorization schemes', () => { + expect(authenticateRemoteDaemonBearerToken('Basic abc123', [createClientRecord('client-1', 'secret-token')])).toEqual({ + ok: false, + statusCode: 401, + error: { + message: 'Remote daemon bearer token is required', + code: 'ERR_REMOTE_DAEMON_AUTH_REQUIRED', + }, + }); + }); +}); diff --git a/main/src/daemon/auth.ts b/main/src/daemon/auth.ts new file mode 100644 index 00000000..4139ff7f --- /dev/null +++ b/main/src/daemon/auth.ts @@ -0,0 +1,92 @@ +import { createHash, timingSafeEqual } from 'crypto'; +import type { RemoteDaemonClientRecord } from '../../../shared/types/remoteDaemon'; + +interface RemoteDaemonAuthSuccess { + ok: true; + client: RemoteDaemonClientRecord; +} + +interface RemoteDaemonAuthFailure { + ok: false; + statusCode: number; + error: { + message: string; + code: string; + }; +} + +export type RemoteDaemonAuthResult = RemoteDaemonAuthSuccess | RemoteDaemonAuthFailure; + +export function hashRemoteDaemonToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export function authenticateRemoteDaemonBearerToken( + authorizationHeader: string | string[] | undefined, + clients: readonly RemoteDaemonClientRecord[], +): RemoteDaemonAuthResult { + const token = extractBearerToken(authorizationHeader); + if (!token) { + return { + ok: false, + statusCode: 401, + error: { + message: 'Remote daemon bearer token is required', + code: 'ERR_REMOTE_DAEMON_AUTH_REQUIRED', + }, + }; + } + + const tokenHash = hashRemoteDaemonToken(token); + const client = clients.find((candidate) => safeTokenHashEquals(candidate.tokenHash, tokenHash)); + if (!client) { + return { + ok: false, + statusCode: 403, + error: { + message: 'Remote daemon bearer token is invalid', + code: 'ERR_REMOTE_DAEMON_AUTH_INVALID', + }, + }; + } + + return { + ok: true, + client, + }; +} + +function extractBearerToken(authorizationHeader: string | string[] | undefined): string | null { + if (Array.isArray(authorizationHeader)) { + return authorizationHeader.length === 1 + ? extractBearerToken(authorizationHeader[0]) + : null; + } + + if (typeof authorizationHeader !== 'string') { + return null; + } + + const [scheme, ...rest] = authorizationHeader.trim().split(/\s+/); + if (scheme.toLowerCase() !== 'bearer') { + return null; + } + + const token = rest.join(' ').trim(); + return token.length > 0 ? token : null; +} + +function safeTokenHashEquals(expectedHash: string, actualHash: string): boolean { + try { + const expected = Buffer.from(expectedHash, 'hex'); + const actual = Buffer.from(actualHash, 'hex'); + + if (expected.length === 0 || expected.length !== actual.length) { + return false; + } + + return timingSafeEqual(expected, actual); + } catch { + return false; + } +} diff --git a/main/src/daemon/bootstrap.ts b/main/src/daemon/bootstrap.ts index b22d7626..144dc101 100644 --- a/main/src/daemon/bootstrap.ts +++ b/main/src/daemon/bootstrap.ts @@ -20,6 +20,7 @@ import { VersionChecker } from '../services/versionChecker'; import { TaskQueue } from '../services/taskQueue'; import { registerIpcHandlers } from '../ipc'; import { PaneDaemonServer } from './server'; +import { PaneRemoteHttpApiServer } from './httpApiServer'; import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from '../core/eventSink'; import { setPaneRuntime, @@ -40,6 +41,7 @@ interface PaneDaemonHostOptions { rendererEventSink?: PaneEventSink; mode?: 'desktop' | 'headless'; restoreSpotlights?: boolean; + startRemoteTransport?: boolean; } export interface PaneDaemonHost { @@ -47,6 +49,7 @@ export interface PaneDaemonHost { daemonServices: DaemonHostServices; commandRegistry: PaneCommandRegistry; paneDaemonServer: PaneDaemonServer | null; + remoteHttpApiServer: PaneRemoteHttpApiServer | null; permissionIpcServer: PermissionIpcServer | null; shutdown(): Promise; } @@ -83,6 +86,7 @@ function registerPowerMonitorDiagnostics(logger: Logger): void { export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Promise { const mode = options.mode ?? 'desktop'; + const startRemoteTransport = options.startRemoteTransport ?? true; const rendererEventSink = options.rendererEventSink ?? noopPaneEventSink; const headlessWebviewContextMap = new Map(); const getWebviewContextMap = options.getWebviewContextMap ?? (() => headlessWebviewContextMap); @@ -204,20 +208,40 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom const commandRegistry = registerIpcHandlers(services); let paneDaemonServer: PaneDaemonServer | null = null; + let remoteHttpApiServer: PaneRemoteHttpApiServer | 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); } + if (startRemoteTransport && configManager.getConfig().remoteDaemon?.host.config.enabled) { + try { + remoteHttpApiServer = new PaneRemoteHttpApiServer(commandRegistry, configManager); + await remoteHttpApiServer.start(); + } catch (error) { + console.error('[Pane remote daemon] Failed to start remote HTTP transport; continuing without remote access', error); + remoteHttpApiServer = null; + } + } + + const daemonSinks: PaneEventSink[] = []; + if (paneDaemonServer) { + daemonSinks.push(paneDaemonServer.getEventSink()); + } + if (remoteHttpApiServer) { + daemonSinks.push(remoteHttpApiServer.getEventSink()); + } + + installPaneRuntime( + createFanoutEventSink([rendererEventSink, ...daemonSinks]), + configManager, + options.getPtyHostRuntime, + getWebviewContextMap, + createFanoutEventSink(daemonSinks), + ); + setupEventListeners(services); const { logsManager } = await import('../services/panels/logPanel/logsManager'); @@ -245,6 +269,7 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom daemonServices, commandRegistry, paneDaemonServer, + remoteHttpApiServer, permissionIpcServer, async shutdown(): Promise { resourceMonitorService.stop(); @@ -256,6 +281,7 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom await cliManagerFactory.shutdown(); await taskQueue.close(); await permissionIpcServer?.stop(); + await remoteHttpApiServer?.stop(); if (paneDaemonServer) { await paneDaemonServer.stop(); } diff --git a/main/src/daemon/httpApiServer.test.ts b/main/src/daemon/httpApiServer.test.ts new file mode 100644 index 00000000..6ddc573b --- /dev/null +++ b/main/src/daemon/httpApiServer.test.ts @@ -0,0 +1,341 @@ +import http from 'http'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createDefaultRemoteDaemonConfig, type RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import { PaneCommandRegistry } from './commandRegistry'; +import { hashRemoteDaemonToken } from './auth'; +import { PaneRemoteHttpApiServer } from './httpApiServer'; + +interface ConfigManagerStub { + getConfig(): { remoteDaemon?: RemoteDaemonConfig }; +} + +interface TestEventStream { + close(): void; + nextEvent(timeoutMs?: number): Promise<{ event: string | null; data: string[] }>; +} + +const activeServers: PaneRemoteHttpApiServer[] = []; +const activeRequests = new Set(); + +afterEach(async () => { + for (const request of activeRequests) { + request.destroy(); + } + activeRequests.clear(); + + for (const server of activeServers.splice(0)) { + await server.stop(); + } +}); + +function createConfigManagerStub(config?: RemoteDaemonConfig): ConfigManagerStub { + const remoteDaemon = config; + + return { + getConfig() { + return { remoteDaemon }; + }, + }; +} + +function createEnabledRemoteConfig(overrides?: Partial): RemoteDaemonConfig { + const config = createDefaultRemoteDaemonConfig(); + config.host.config = { + ...config.host.config, + enabled: true, + listenHost: '127.0.0.1', + listenPort: 0, + ...overrides, + }; + config.host.clients = [{ + id: 'client-1', + label: 'Mac mini', + createdAt: new Date('2026-05-14T00:00:00.000Z').toISOString(), + tokenHash: hashRemoteDaemonToken('secret-token'), + }]; + return config; +} + +async function requestJson( + server: PaneRemoteHttpApiServer, + method: 'GET' | 'POST', + path: string, + body?: unknown, + token?: string, +): Promise<{ statusCode: number; body: unknown }> { + const address = server.getAddress(); + if (!address) { + throw new Error('Remote HTTP API server is not listening'); + } + + return new Promise((resolve, reject) => { + const request = http.request({ + host: address.host, + port: address.port, + path, + method, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + }, (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + }); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + resolve({ + statusCode: response.statusCode ?? 0, + body: text.length > 0 ? JSON.parse(text) : null, + }); + }); + }); + + activeRequests.add(request); + request.once('error', reject); + if (body !== undefined) { + request.write(JSON.stringify(body)); + } + request.end(); + }); +} + +async function openEventStream(server: PaneRemoteHttpApiServer, token: string): Promise { + const address = server.getAddress(); + if (!address) { + throw new Error('Remote HTTP API server is not listening'); + } + + return new Promise((resolve, reject) => { + const request = http.request({ + host: address.host, + port: address.port, + path: '/events', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + activeRequests.add(request); + request.once('error', reject); + request.on('response', (response) => { + const queuedEvents: Array<{ event: string | null; data: string[] }> = []; + const waiters: Array<(event: { event: string | null; data: string[] }) => void> = []; + let buffer = ''; + + response.on('data', (chunk) => { + buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + + let boundaryIndex = buffer.indexOf('\n\n'); + while (boundaryIndex !== -1) { + const rawEvent = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + 2); + + const parsedEvent = parseSseEvent(rawEvent); + if (parsedEvent) { + const waiter = waiters.shift(); + if (waiter) { + waiter(parsedEvent); + } else { + queuedEvents.push(parsedEvent); + } + } + + boundaryIndex = buffer.indexOf('\n\n'); + } + }); + + resolve({ + close() { + request.destroy(); + }, + nextEvent(timeoutMs = 1000) { + if (queuedEvents.length > 0) { + return Promise.resolve(queuedEvents.shift() as { event: string | null; data: string[] }); + } + + return new Promise((eventResolve, eventReject) => { + const timeout = setTimeout(() => { + eventReject(new Error('Timed out waiting for SSE event')); + }, timeoutMs); + + waiters.push((event) => { + clearTimeout(timeout); + eventResolve(event); + }); + }); + }, + }); + }); + + request.end(); + }); +} + +function parseSseEvent(rawEvent: string): { event: string | null; data: string[] } | null { + const lines = rawEvent.split('\n'); + let event: string | null = null; + const data: string[] = []; + + for (const line of lines) { + if (line.startsWith('event: ')) { + event = line.slice('event: '.length); + continue; + } + + if (line.startsWith('data: ')) { + data.push(line.slice('data: '.length)); + } + } + + if (!event && data.length === 0) { + return null; + } + + return { event, data }; +} + +describe('PaneRemoteHttpApiServer', () => { + it('invokes daemon-owned commands over authenticated HTTP', async () => { + const registry = new PaneCommandRegistry(); + registry.register('sessions:get-all', async () => [{ id: 'session-1' }]); + + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(createEnabledRemoteConfig()) as never); + activeServers.push(server); + await server.start(); + + await expect(requestJson(server, 'POST', '/invoke', { + channel: 'sessions:get-all', + args: [], + }, 'secret-token')).resolves.toEqual({ + statusCode: 200, + body: { + ok: true, + result: [{ id: 'session-1' }], + }, + }); + }); + + it('rejects invoke requests without bearer auth', async () => { + const registry = new PaneCommandRegistry(); + registry.register('sessions:get-all', async () => []); + + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(createEnabledRemoteConfig()) as never); + activeServers.push(server); + await server.start(); + + await expect(requestJson(server, 'POST', '/invoke', { + channel: 'sessions:get-all', + args: [], + })).resolves.toEqual({ + statusCode: 401, + body: { + ok: false, + statusCode: 401, + error: { + message: 'Remote daemon bearer token is required', + code: 'ERR_REMOTE_DAEMON_AUTH_REQUIRED', + }, + }, + }); + }); + + it('rejects oversized invoke request bodies with a client error', async () => { + const registry = new PaneCommandRegistry(); + registry.register('sessions:get-all', async () => []); + + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(createEnabledRemoteConfig()) as never); + activeServers.push(server); + await server.start(); + + const oversizedArgs = ['x'.repeat(1024 * 1024)]; + await expect(requestJson(server, 'POST', '/invoke', { + channel: 'sessions:get-all', + args: oversizedArgs, + }, 'secret-token')).resolves.toEqual({ + statusCode: 413, + body: { + ok: false, + error: { + message: 'Remote daemon request body exceeds the 1 MB limit', + code: 'ERR_REMOTE_DAEMON_REQUEST_TOO_LARGE', + }, + }, + }); + }); + + it('streams a ready event and daemon-owned runtime events over SSE', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(createEnabledRemoteConfig()) as never); + activeServers.push(server); + await server.start(); + + const stream = await openEventStream(server, 'secret-token'); + const readyEvent = await stream.nextEvent(); + expect(readyEvent.event).toBe('ready'); + expect(JSON.parse(readyEvent.data.join('\n'))).toMatchObject({ + replay: 'none', + resync: 'refetch-state-after-reconnect', + }); + + server.getEventSink().send('session:created', { id: 'session-1' }); + + const daemonEvent = await stream.nextEvent(); + expect(daemonEvent.event).toBe('daemon-event'); + expect(JSON.parse(daemonEvent.data.join('\n'))).toEqual({ + channel: 'session:created', + args: [{ id: 'session-1' }], + timestamp: expect.any(String), + }); + + stream.close(); + }); + + it('filters non-daemon events from the remote SSE stream', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(createEnabledRemoteConfig()) as never); + activeServers.push(server); + await server.start(); + + const stream = await openEventStream(server, 'secret-token'); + await stream.nextEvent(); + + server.getEventSink().send('version:update-available', { version: '1.2.3' }); + await expect(stream.nextEvent(100)).rejects.toThrow('Timed out waiting for SSE event'); + + stream.close(); + }); + + it('drops existing SSE subscribers when the paired client token rotates', async () => { + const registry = new PaneCommandRegistry(); + const remoteConfig = createEnabledRemoteConfig(); + const server = new PaneRemoteHttpApiServer(registry, createConfigManagerStub(remoteConfig) as never); + activeServers.push(server); + await server.start(); + + const stream = await openEventStream(server, 'secret-token'); + await stream.nextEvent(); + + remoteConfig.host.clients = [{ + ...remoteConfig.host.clients[0], + tokenHash: hashRemoteDaemonToken('rotated-token'), + }]; + + server.getEventSink().send('session:created', { id: 'session-1' }); + await expect(stream.nextEvent(100)).rejects.toThrow('Timed out waiting for SSE event'); + + stream.close(); + }); + + it('refuses direct loopback HTTP when config disables insecure loopback mode', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneRemoteHttpApiServer( + registry, + createConfigManagerStub(createEnabledRemoteConfig({ allowInsecureHttpOnLoopback: false })) as never, + ); + + await expect(server.start()).rejects.toThrow('Remote daemon HTTP API loopback transport is disabled by config'); + }); +}); diff --git a/main/src/daemon/httpApiServer.ts b/main/src/daemon/httpApiServer.ts new file mode 100644 index 00000000..0629a1bc --- /dev/null +++ b/main/src/daemon/httpApiServer.ts @@ -0,0 +1,445 @@ +import http, { type IncomingMessage, type ServerResponse } from 'http'; +import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from '../core/eventSink'; +import type { ConfigManager } from '../services/configManager'; +import type { PaneCommandRegistry } from './commandRegistry'; +import { authenticateRemoteDaemonBearerToken } from './auth'; +import { isPaneDaemonEventChannel } from './server'; +import { + createDefaultRemoteDaemonConfig, + type RemoteDaemonConfig, + type RemoteDaemonEventEnvelope, + type RemoteInvokeRequest, +} from '../../../shared/types/remoteDaemon'; + +interface RemoteHttpAddress { + host: string; + port: number; +} + +interface ConnectedRemoteEventClient { + id: string; + response: ServerResponse; + remoteClientId: string; + remoteClientTokenHash: string; +} + +interface RemoteInvokeSuccessPayload { + ok: true; + result: unknown; +} + +interface RemoteInvokeErrorPayload { + ok: false; + error: { + message: string; + code: string; + }; +} + +interface RemoteReadyEventPayload { + replay: 'none'; + resync: 'refetch-state-after-reconnect'; + timestamp: string; +} + +const MAX_REQUEST_BODY_BYTES = 1024 * 1024; + +class RemoteDaemonBadRequestError extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode: number = 400, + ) { + super(message); + this.name = 'RemoteDaemonBadRequestError'; + } +} + +export class PaneRemoteHttpApiServer { + private server: http.Server | null = null; + private readonly eventClients = new Map(); + private readonly daemonEventSink: PaneEventSink; + private address: RemoteHttpAddress | null = null; + private nextClientConnectionId = 1; + + constructor( + private readonly commandRegistry: PaneCommandRegistry, + private readonly configManager: ConfigManager, + ) { + this.daemonEventSink = createFanoutEventSink([ + { + send: (channel, ...args) => { + if (!isPaneDaemonEventChannel(channel) || this.eventClients.size === 0) { + return; + } + + const payload: RemoteDaemonEventEnvelope = { + channel, + args, + timestamp: new Date().toISOString(), + }; + + for (const [clientConnectionId, client] of this.eventClients) { + if (!this.shouldKeepEventClient(client.remoteClientId, client.remoteClientTokenHash)) { + this.dropEventClient(clientConnectionId); + continue; + } + + try { + writeSseEvent(client.response, 'daemon-event', payload); + } catch { + this.dropEventClient(clientConnectionId); + } + } + }, + }, + noopPaneEventSink, + ]); + } + + getAddress(): RemoteHttpAddress | null { + return this.address; + } + + getEventSink(): PaneEventSink { + return this.daemonEventSink; + } + + async start(): Promise { + if (this.server) { + throw new Error('Remote daemon HTTP API server is already running'); + } + + const hostConfig = this.getRemoteConfig().host.config; + if (!hostConfig.enabled) { + throw new Error('Remote daemon HTTP API server is disabled in config'); + } + + if (isLoopbackHost(hostConfig.listenHost) && !hostConfig.allowInsecureHttpOnLoopback) { + throw new Error('Remote daemon HTTP API loopback transport is disabled by config'); + } + + const server = http.createServer((request, response) => { + void this.handleRequest(request, response).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + if (!response.headersSent) { + this.writeJson(response, 500, { + ok: false, + error: { + message, + code: 'ERR_REMOTE_DAEMON_HTTP_INTERNAL', + }, + }); + return; + } + + response.destroy(error instanceof Error ? error : new Error(message)); + }); + }); + + await new Promise((resolve, reject) => { + const handleError = (error: Error) => { + server.removeListener('listening', handleListening); + reject(error); + }; + + const handleListening = () => { + server.removeListener('error', handleError); + resolve(); + }; + + server.once('error', handleError); + server.once('listening', handleListening); + server.listen(hostConfig.listenPort, hostConfig.listenHost); + }); + + server.on('error', (error) => { + console.error('[Pane remote daemon] HTTP server error:', error); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Remote daemon HTTP API server did not expose a TCP address'); + } + + this.server = server; + this.address = { + host: hostConfig.listenHost, + port: address.port, + }; + } + + async stop(): Promise { + const server = this.server; + this.server = null; + this.address = null; + + for (const clientConnectionId of [...this.eventClients.keys()]) { + this.dropEventClient(clientConnectionId); + } + + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + } + + private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); + + if (url.pathname === '/invoke') { + await this.handleInvokeRequest(request, response); + return; + } + + if (url.pathname === '/events') { + this.handleEventStreamRequest(request, response); + return; + } + + this.writeJson(response, 404, { + ok: false, + error: { + message: `Remote daemon endpoint "${url.pathname}" does not exist`, + code: 'ERR_REMOTE_DAEMON_HTTP_NOT_FOUND', + }, + }); + } + + private async handleInvokeRequest(request: IncomingMessage, response: ServerResponse): Promise { + if (request.method !== 'POST') { + this.writeMethodNotAllowed(response, 'POST'); + return; + } + + const auth = this.authenticateRequest(request); + if (!auth.ok) { + this.writeJson(response, auth.statusCode, auth); + return; + } + + let invokeRequest: RemoteInvokeRequest; + try { + invokeRequest = await this.readInvokeRequest(request); + } catch (error) { + if (error instanceof RemoteDaemonBadRequestError) { + this.writeJson(response, error.statusCode, { + ok: false, + error: { + message: error.message, + code: error.code, + }, + }); + return; + } + + throw error; + } + + try { + const result = await this.commandRegistry.invoke(invokeRequest.channel, invokeRequest.args); + this.writeJson(response, 200, { + ok: true, + result, + } satisfies RemoteInvokeSuccessPayload); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const code = message.includes('No Pane daemon command registered') + ? 'ERR_UNKNOWN_CHANNEL' + : 'ERR_REMOTE_DAEMON_REQUEST_FAILED'; + const statusCode = code === 'ERR_UNKNOWN_CHANNEL' ? 404 : 500; + + this.writeJson(response, statusCode, { + ok: false, + error: { + message, + code, + }, + } satisfies RemoteInvokeErrorPayload); + } + } + + private handleEventStreamRequest(request: IncomingMessage, response: ServerResponse): void { + if (request.method !== 'GET') { + this.writeMethodNotAllowed(response, 'GET'); + return; + } + + const auth = this.authenticateRequest(request); + if (!auth.ok) { + this.writeJson(response, auth.statusCode, auth); + return; + } + + response.writeHead(200, { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream; charset=utf-8', + 'X-Accel-Buffering': 'no', + }); + response.flushHeaders(); + response.write('retry: 1000\n\n'); + writeSseEvent(response, 'ready', { + replay: 'none', + resync: 'refetch-state-after-reconnect', + timestamp: new Date().toISOString(), + } satisfies RemoteReadyEventPayload); + + const clientConnectionId = String(this.nextClientConnectionId++); + this.eventClients.set(clientConnectionId, { + id: clientConnectionId, + response, + remoteClientId: auth.client.id, + remoteClientTokenHash: auth.client.tokenHash, + }); + + const cleanup = () => { + this.eventClients.delete(clientConnectionId); + }; + + request.on('close', cleanup); + response.on('close', cleanup); + } + + private authenticateRequest(request: IncomingMessage) { + const remoteConfig = this.getRemoteConfig(); + if (!remoteConfig.host.config.enabled) { + return { + ok: false as const, + statusCode: 503, + error: { + message: 'Remote daemon HTTP API is disabled', + code: 'ERR_REMOTE_DAEMON_HTTP_DISABLED', + }, + }; + } + + return authenticateRemoteDaemonBearerToken( + request.headers.authorization, + remoteConfig.host.clients, + ); + } + + private async readInvokeRequest( + request: IncomingMessage, + ): Promise { + const body = await readRequestBody(request); + if (body.length === 0) { + throw new RemoteDaemonBadRequestError( + 'ERR_REMOTE_DAEMON_BAD_REQUEST', + 'Remote daemon invoke request body is required', + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body) as unknown; + } catch (error) { + throw new RemoteDaemonBadRequestError( + 'ERR_REMOTE_DAEMON_BAD_REQUEST', + `Failed to parse remote daemon invoke request: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (!isRemoteInvokeRequest(parsed)) { + throw new RemoteDaemonBadRequestError( + 'ERR_REMOTE_DAEMON_BAD_REQUEST', + 'Remote daemon invoke request must contain a channel string and args array', + ); + } + + return parsed; + } + + private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void { + response.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify(payload)); + } + + private writeMethodNotAllowed(response: ServerResponse, method: 'GET' | 'POST'): void { + response.writeHead(405, { + Allow: method, + 'Content-Type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ + ok: false, + error: { + message: `Remote daemon endpoint only supports ${method}`, + code: 'ERR_REMOTE_DAEMON_METHOD_NOT_ALLOWED', + }, + })); + } + + private shouldKeepEventClient(remoteClientId: string, remoteClientTokenHash: string): boolean { + const remoteConfig = this.getRemoteConfig(); + if (!remoteConfig.host.config.enabled) { + return false; + } + + return remoteConfig.host.clients.some((client) => ( + client.id === remoteClientId && + client.tokenHash === remoteClientTokenHash + )); + } + + private dropEventClient(clientConnectionId: string): void { + const client = this.eventClients.get(clientConnectionId); + if (!client) { + return; + } + + this.eventClients.delete(clientConnectionId); + if (!client.response.writableEnded) { + client.response.end(); + } + } + + private getRemoteConfig(): RemoteDaemonConfig { + return this.configManager.getConfig().remoteDaemon ?? createDefaultRemoteDaemonConfig(); + } +} + +async function readRequestBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + + for await (const chunk of request) { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + totalBytes += buffer.length; + + if (totalBytes > MAX_REQUEST_BODY_BYTES) { + throw new RemoteDaemonBadRequestError( + 'ERR_REMOTE_DAEMON_REQUEST_TOO_LARGE', + 'Remote daemon request body exceeds the 1 MB limit', + 413, + ); + } + + chunks.push(buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +} + +function writeSseEvent(response: ServerResponse, eventName: string, payload: RemoteReadyEventPayload | RemoteDaemonEventEnvelope): void { + response.write(`event: ${eventName}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +function isLoopbackHost(host: string): boolean { + return host === '127.0.0.1' || host === '::1' || host === 'localhost'; +} + +function isRemoteInvokeRequest(value: unknown): value is RemoteInvokeRequest { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const candidate = value as Partial; + return typeof candidate.channel === 'string' && candidate.channel.length > 0 && Array.isArray(candidate.args); +} diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index 096d987d..f5641c0b 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -35,7 +35,7 @@ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'script-session-changed', ]); -function isPaneDaemonEventChannel(channel: string): boolean { +export function isPaneDaemonEventChannel(channel: string): boolean { if (DAEMON_EVENT_EXACT_CHANNELS.has(channel)) { return true; } From aabe811e78b94449eb2cef4d54d84f4986c4984d Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:20:59 -0700 Subject: [PATCH 031/111] fix: harden remote daemon HTTP transport --- main/src/daemon/bootstrap.ts | 20 +- main/src/daemon/httpApiServer.test.ts | 12 + main/src/daemon/httpApiServer.ts | 10 +- .../daemon/remoteTransportController.test.ts | 222 ++++++++++++++++++ main/src/daemon/remoteTransportController.ts | 110 +++++++++ main/src/ipc/remoteDaemon.test.ts | 18 ++ main/src/ipc/remoteDaemon.ts | 6 + shared/types/remoteDaemon.ts | 21 ++ 8 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 main/src/daemon/remoteTransportController.test.ts create mode 100644 main/src/daemon/remoteTransportController.ts diff --git a/main/src/daemon/bootstrap.ts b/main/src/daemon/bootstrap.ts index 144dc101..39b324a6 100644 --- a/main/src/daemon/bootstrap.ts +++ b/main/src/daemon/bootstrap.ts @@ -21,6 +21,7 @@ import { TaskQueue } from '../services/taskQueue'; import { registerIpcHandlers } from '../ipc'; import { PaneDaemonServer } from './server'; import { PaneRemoteHttpApiServer } from './httpApiServer'; +import { PaneRemoteTransportController } from './remoteTransportController'; import { createFanoutEventSink, noopPaneEventSink, type PaneEventSink } from '../core/eventSink'; import { setPaneRuntime, @@ -208,7 +209,7 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom const commandRegistry = registerIpcHandlers(services); let paneDaemonServer: PaneDaemonServer | null = null; - let remoteHttpApiServer: PaneRemoteHttpApiServer | null = null; + const remoteTransportController = new PaneRemoteTransportController(commandRegistry, configManager); try { paneDaemonServer = new PaneDaemonServer(commandRegistry, getAppDirectory()); await paneDaemonServer.start(); @@ -216,13 +217,12 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom console.error('[Pane daemon] Failed to start local daemon server; continuing with renderer-only runtime events', error); } - if (startRemoteTransport && configManager.getConfig().remoteDaemon?.host.config.enabled) { + if (startRemoteTransport) { + remoteTransportController.startWatchingConfig(); try { - remoteHttpApiServer = new PaneRemoteHttpApiServer(commandRegistry, configManager); - await remoteHttpApiServer.start(); + await remoteTransportController.syncToConfig(); } catch (error) { console.error('[Pane remote daemon] Failed to start remote HTTP transport; continuing without remote access', error); - remoteHttpApiServer = null; } } @@ -230,8 +230,8 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom if (paneDaemonServer) { daemonSinks.push(paneDaemonServer.getEventSink()); } - if (remoteHttpApiServer) { - daemonSinks.push(remoteHttpApiServer.getEventSink()); + if (startRemoteTransport) { + daemonSinks.push(remoteTransportController.getEventSink()); } installPaneRuntime( @@ -269,7 +269,9 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom daemonServices, commandRegistry, paneDaemonServer, - remoteHttpApiServer, + get remoteHttpApiServer() { + return remoteTransportController.getServer(); + }, permissionIpcServer, async shutdown(): Promise { resourceMonitorService.stop(); @@ -281,7 +283,7 @@ export async function createPaneDaemonHost(options: PaneDaemonHostOptions): Prom await cliManagerFactory.shutdown(); await taskQueue.close(); await permissionIpcServer?.stop(); - await remoteHttpApiServer?.stop(); + await remoteTransportController.stopWatchingAndShutdown(); if (paneDaemonServer) { await paneDaemonServer.stop(); } diff --git a/main/src/daemon/httpApiServer.test.ts b/main/src/daemon/httpApiServer.test.ts index 6ddc573b..dcb34d0e 100644 --- a/main/src/daemon/httpApiServer.test.ts +++ b/main/src/daemon/httpApiServer.test.ts @@ -338,4 +338,16 @@ describe('PaneRemoteHttpApiServer', () => { await expect(server.start()).rejects.toThrow('Remote daemon HTTP API loopback transport is disabled by config'); }); + + it('refuses direct HTTP on non-loopback listen hosts', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneRemoteHttpApiServer( + registry, + createConfigManagerStub(createEnabledRemoteConfig({ listenHost: '0.0.0.0' })) as never, + ); + + await expect(server.start()).rejects.toThrow( + 'Remote daemon direct HTTP only supports loopback listen hosts; keep listenHost on 127.0.0.1, ::1, or localhost and expose it through an SSH tunnel, Tailscale/VPN, or a reverse proxy.', + ); + }); }); diff --git a/main/src/daemon/httpApiServer.ts b/main/src/daemon/httpApiServer.ts index 0629a1bc..c8e6e2de 100644 --- a/main/src/daemon/httpApiServer.ts +++ b/main/src/daemon/httpApiServer.ts @@ -6,6 +6,7 @@ import { authenticateRemoteDaemonBearerToken } from './auth'; import { isPaneDaemonEventChannel } from './server'; import { createDefaultRemoteDaemonConfig, + getRemoteDaemonHostConfigValidationError, type RemoteDaemonConfig, type RemoteDaemonEventEnvelope, type RemoteInvokeRequest, @@ -115,8 +116,9 @@ export class PaneRemoteHttpApiServer { throw new Error('Remote daemon HTTP API server is disabled in config'); } - if (isLoopbackHost(hostConfig.listenHost) && !hostConfig.allowInsecureHttpOnLoopback) { - throw new Error('Remote daemon HTTP API loopback transport is disabled by config'); + const hostConfigError = getRemoteDaemonHostConfigValidationError(hostConfig); + if (hostConfigError) { + throw new Error(hostConfigError); } const server = http.createServer((request, response) => { @@ -431,10 +433,6 @@ function writeSseEvent(response: ServerResponse, eventName: string, payload: Rem response.write(`data: ${JSON.stringify(payload)}\n\n`); } -function isLoopbackHost(host: string): boolean { - return host === '127.0.0.1' || host === '::1' || host === 'localhost'; -} - function isRemoteInvokeRequest(value: unknown): value is RemoteInvokeRequest { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; diff --git a/main/src/daemon/remoteTransportController.test.ts b/main/src/daemon/remoteTransportController.test.ts new file mode 100644 index 00000000..b8591b28 --- /dev/null +++ b/main/src/daemon/remoteTransportController.test.ts @@ -0,0 +1,222 @@ +import http from 'http'; +import { EventEmitter } from 'events'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createDefaultRemoteDaemonConfig, type RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import { hashRemoteDaemonToken } from './auth'; +import { PaneCommandRegistry } from './commandRegistry'; +import { PaneRemoteTransportController } from './remoteTransportController'; + +interface TestEventStream { + close(): void; + nextEvent(timeoutMs?: number): Promise<{ event: string | null; data: string[] }>; +} + +class ConfigManagerStub extends EventEmitter { + private remoteDaemon: RemoteDaemonConfig; + + constructor(initialConfig: RemoteDaemonConfig) { + super(); + this.remoteDaemon = initialConfig; + } + + getConfig(): { remoteDaemon: RemoteDaemonConfig } { + return { remoteDaemon: this.remoteDaemon }; + } + + async updateRemoteDaemonConfig(remoteDaemon: RemoteDaemonConfig): Promise { + this.remoteDaemon = remoteDaemon; + this.emit('config-updated', { remoteDaemon }); + } +} + +const activeControllers: PaneRemoteTransportController[] = []; +const activeRequests = new Set(); + +afterEach(async () => { + for (const request of activeRequests) { + request.destroy(); + } + activeRequests.clear(); + + for (const controller of activeControllers.splice(0)) { + await controller.stopWatchingAndShutdown(); + } +}); + +function createEnabledRemoteConfig(overrides?: Partial): RemoteDaemonConfig { + const config = createDefaultRemoteDaemonConfig(); + config.host.config = { + ...config.host.config, + enabled: true, + listenHost: '127.0.0.1', + listenPort: 0, + ...overrides, + }; + config.host.clients = [{ + id: 'client-1', + label: 'Mac mini', + createdAt: new Date('2026-05-14T00:00:00.000Z').toISOString(), + tokenHash: hashRemoteDaemonToken('secret-token'), + }]; + return config; +} + +async function openEventStream(server: NonNullable>, token: string): Promise { + const address = server.getAddress(); + if (!address) { + throw new Error('Remote HTTP API server is not listening'); + } + + return new Promise((resolve, reject) => { + const request = http.request({ + host: address.host, + port: address.port, + path: '/events', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + activeRequests.add(request); + request.once('error', reject); + request.on('response', (response) => { + const queuedEvents: Array<{ event: string | null; data: string[] }> = []; + const waiters: Array<(event: { event: string | null; data: string[] }) => void> = []; + let buffer = ''; + + response.on('data', (chunk) => { + buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + + let boundaryIndex = buffer.indexOf('\n\n'); + while (boundaryIndex !== -1) { + const rawEvent = buffer.slice(0, boundaryIndex); + buffer = buffer.slice(boundaryIndex + 2); + + const parsedEvent = parseSseEvent(rawEvent); + if (parsedEvent) { + const waiter = waiters.shift(); + if (waiter) { + waiter(parsedEvent); + } else { + queuedEvents.push(parsedEvent); + } + } + + boundaryIndex = buffer.indexOf('\n\n'); + } + }); + + resolve({ + close() { + request.destroy(); + }, + nextEvent(timeoutMs = 1000) { + if (queuedEvents.length > 0) { + return Promise.resolve(queuedEvents.shift() as { event: string | null; data: string[] }); + } + + return new Promise((eventResolve, eventReject) => { + const timeout = setTimeout(() => { + eventReject(new Error('Timed out waiting for SSE event')); + }, timeoutMs); + + waiters.push((event) => { + clearTimeout(timeout); + eventResolve(event); + }); + }); + }, + }); + }); + + request.end(); + }); +} + +function parseSseEvent(rawEvent: string): { event: string | null; data: string[] } | null { + const lines = rawEvent.split('\n'); + let event: string | null = null; + const data: string[] = []; + + for (const line of lines) { + if (line.startsWith('event: ')) { + event = line.slice('event: '.length); + continue; + } + + if (line.startsWith('data: ')) { + data.push(line.slice('data: '.length)); + } + } + + if (!event && data.length === 0) { + return null; + } + + return { event, data }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1500): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('PaneRemoteTransportController', () => { + it('starts and stops remote HTTP transport on config updates while keeping a stable event sink', async () => { + const registry = new PaneCommandRegistry(); + const configManager = new ConfigManagerStub(createDefaultRemoteDaemonConfig()); + const controller = new PaneRemoteTransportController(registry, configManager as never); + activeControllers.push(controller); + controller.startWatchingConfig(); + + const daemonEventSink = controller.getEventSink(); + await controller.syncToConfig(); + expect(controller.getServer()).toBeNull(); + + await configManager.updateRemoteDaemonConfig(createEnabledRemoteConfig()); + await waitFor(() => controller.getServer() !== null); + + const server = controller.getServer(); + if (!server) { + throw new Error('Remote HTTP API server did not start'); + } + + const stream = await openEventStream(server, 'secret-token'); + await stream.nextEvent(); + + daemonEventSink.send('session:created', { id: 'session-1' }); + + const daemonEvent = await stream.nextEvent(); + expect(daemonEvent.event).toBe('daemon-event'); + expect(JSON.parse(daemonEvent.data.join('\n'))).toEqual({ + channel: 'session:created', + args: [{ id: 'session-1' }], + timestamp: expect.any(String), + }); + + stream.close(); + await configManager.updateRemoteDaemonConfig(createDefaultRemoteDaemonConfig()); + await waitFor(() => controller.getServer() === null); + }); + + it('stops the active remote HTTP transport when config changes to an invalid non-loopback bind', async () => { + const registry = new PaneCommandRegistry(); + const configManager = new ConfigManagerStub(createEnabledRemoteConfig()); + const controller = new PaneRemoteTransportController(registry, configManager as never); + activeControllers.push(controller); + controller.startWatchingConfig(); + + await controller.syncToConfig(); + expect(controller.getServer()).not.toBeNull(); + + await configManager.updateRemoteDaemonConfig(createEnabledRemoteConfig({ listenHost: '0.0.0.0' })); + await waitFor(() => controller.getServer() === null); + }); +}); diff --git a/main/src/daemon/remoteTransportController.ts b/main/src/daemon/remoteTransportController.ts new file mode 100644 index 00000000..084c1fd2 --- /dev/null +++ b/main/src/daemon/remoteTransportController.ts @@ -0,0 +1,110 @@ +import type { PaneEventSink } from '../core/eventSink'; +import type { ConfigManager } from '../services/configManager'; +import { getRemoteDaemonHostConfigValidationError } from '../../../shared/types/remoteDaemon'; +import type { RemoteDaemonHostConfig } from '../../../shared/types/remoteDaemon'; +import type { PaneCommandRegistry } from './commandRegistry'; +import { PaneRemoteHttpApiServer } from './httpApiServer'; + +export class PaneRemoteTransportController { + private remoteHttpApiServer: PaneRemoteHttpApiServer | null = null; + private activeBindingKey: string | null = null; + private syncQueue: Promise = Promise.resolve(); + private configListenerAttached = false; + + private readonly configUpdatedListener = () => { + void this.syncToConfig().catch((error) => { + console.error('[Pane remote daemon] Failed to apply remote transport config update', error); + }); + }; + + private readonly eventSink: PaneEventSink = { + send: (channel, ...args) => { + this.remoteHttpApiServer?.getEventSink().send(channel, ...args); + }, + }; + + constructor( + private readonly commandRegistry: PaneCommandRegistry, + private readonly configManager: ConfigManager, + ) {} + + getEventSink(): PaneEventSink { + return this.eventSink; + } + + getServer(): PaneRemoteHttpApiServer | null { + return this.remoteHttpApiServer; + } + + startWatchingConfig(): void { + if (this.configListenerAttached) { + return; + } + + this.configManager.on('config-updated', this.configUpdatedListener); + this.configListenerAttached = true; + } + + async stopWatchingAndShutdown(): Promise { + if (this.configListenerAttached) { + this.configManager.off('config-updated', this.configUpdatedListener); + this.configListenerAttached = false; + } + + await this.enqueueSync(async () => { + await this.stopRemoteHttpServer(); + }); + } + + async syncToConfig(): Promise { + await this.enqueueSync(async () => { + const hostConfig = this.configManager.getConfig().remoteDaemon?.host.config; + if (!hostConfig?.enabled) { + await this.stopRemoteHttpServer(); + return; + } + + const validationError = getRemoteDaemonHostConfigValidationError(hostConfig); + if (validationError) { + await this.stopRemoteHttpServer(); + throw new Error(validationError); + } + + const nextBindingKey = this.getBindingKey(hostConfig); + if (this.remoteHttpApiServer && this.activeBindingKey === nextBindingKey) { + return; + } + + await this.stopRemoteHttpServer(); + + const remoteHttpApiServer = new PaneRemoteHttpApiServer(this.commandRegistry, this.configManager); + await remoteHttpApiServer.start(); + this.remoteHttpApiServer = remoteHttpApiServer; + this.activeBindingKey = nextBindingKey; + }); + } + + private async stopRemoteHttpServer(): Promise { + const remoteHttpApiServer = this.remoteHttpApiServer; + this.remoteHttpApiServer = null; + this.activeBindingKey = null; + + if (remoteHttpApiServer) { + await remoteHttpApiServer.stop(); + } + } + + private enqueueSync(work: () => Promise): Promise { + const nextSync = this.syncQueue.then(work, work); + this.syncQueue = nextSync.catch(() => {}); + return nextSync; + } + + private getBindingKey(config: RemoteDaemonHostConfig): string { + return JSON.stringify({ + listenHost: config.listenHost.trim().toLowerCase(), + listenPort: config.listenPort, + allowInsecureHttpOnLoopback: config.allowInsecureHttpOnLoopback, + }); + } +} diff --git a/main/src/ipc/remoteDaemon.test.ts b/main/src/ipc/remoteDaemon.test.ts index e7c332c4..8e76d1cd 100644 --- a/main/src/ipc/remoteDaemon.test.ts +++ b/main/src/ipc/remoteDaemon.test.ts @@ -186,4 +186,22 @@ describe('remote daemon IPC', () => { error: 'Remote daemon connection profile is invalid', }); }); + + it('rejects enabling direct HTTP on a non-loopback listen host', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const updateHostConfig = ipcMain.handlers.get('remote-daemon:update-host-config'); + + await expect(updateHostConfig?.({}, { + enabled: true, + listenHost: '0.0.0.0', + listenPort: 42137, + })).resolves.toEqual({ + success: false, + error: 'Remote daemon direct HTTP only supports loopback listen hosts; keep listenHost on 127.0.0.1, ::1, or localhost and expose it through an SSH tunnel, Tailscale/VPN, or a reverse proxy.', + }); + }); }); diff --git a/main/src/ipc/remoteDaemon.ts b/main/src/ipc/remoteDaemon.ts index d32712e7..fa99dad1 100644 --- a/main/src/ipc/remoteDaemon.ts +++ b/main/src/ipc/remoteDaemon.ts @@ -1,4 +1,5 @@ import { + getRemoteDaemonHostConfigValidationError, isRemoteDaemonClientRecord, isRemotePaneConnectionProfile, normalizeRemoteDaemonConfig, @@ -42,6 +43,11 @@ export function registerRemoteDaemonHandlers( }, }); + const validationError = getRemoteDaemonHostConfigValidationError(next.host.config); + if (validationError) { + throw new Error(validationError); + } + await configManager.updateConfig({ remoteDaemon: next }); return { success: true, data: next.host.config }; } catch (error) { diff --git a/shared/types/remoteDaemon.ts b/shared/types/remoteDaemon.ts index 89e0a3cc..71b284d8 100644 --- a/shared/types/remoteDaemon.ts +++ b/shared/types/remoteDaemon.ts @@ -60,6 +60,27 @@ export const DEFAULT_REMOTE_DAEMON_HOST_CONFIG: RemoteDaemonHostConfig = { allowInsecureHttpOnLoopback: true, }; +export function isLoopbackRemoteDaemonHost(host: string): boolean { + const normalizedHost = host.trim().toLowerCase(); + return normalizedHost === '127.0.0.1' || normalizedHost === '::1' || normalizedHost === 'localhost'; +} + +export function getRemoteDaemonHostConfigValidationError(config: RemoteDaemonHostConfig): string | null { + if (!config.enabled) { + return null; + } + + if (!isLoopbackRemoteDaemonHost(config.listenHost)) { + return 'Remote daemon direct HTTP only supports loopback listen hosts; keep listenHost on 127.0.0.1, ::1, or localhost and expose it through an SSH tunnel, Tailscale/VPN, or a reverse proxy.'; + } + + if (!config.allowInsecureHttpOnLoopback) { + return 'Remote daemon HTTP API loopback transport is disabled by config'; + } + + return null; +} + export function createDefaultRemoteDaemonConfig(): RemoteDaemonConfig { return { host: { From f14536c457e448534f2c8a08df2af3b7a838623d Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 16:59:52 -0700 Subject: [PATCH 032/111] feat: daemonize permission approval flow --- frontend/src/App.tsx | 74 ++++++++---- frontend/src/components/PermissionDialog.tsx | 20 ++-- frontend/src/types/electron.d.ts | 16 +-- frontend/src/utils/api.ts | 8 +- main/src/core/importBoundary.test.ts | 2 +- main/src/daemon/permissionBroker.test.ts | 98 ++++++++++++++++ main/src/daemon/permissionBroker.ts | 87 ++++++++++++++ main/src/daemon/server.test.ts | 60 ++++++++++ main/src/daemon/server.ts | 2 + main/src/ipc/daemonRegistryBindings.test.ts | 16 +++ main/src/ipc/index.ts | 2 + main/src/ipc/permissions.ts | 35 ++++++ main/src/preload.ts | 41 ++++++- main/src/services/permissionManager.ts | 114 ++++--------------- shared/types/daemon.ts | 23 ++++ 15 files changed, 458 insertions(+), 140 deletions(-) create mode 100644 main/src/daemon/permissionBroker.test.ts create mode 100644 main/src/daemon/permissionBroker.ts create mode 100644 main/src/ipc/permissions.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ac5eeeb..9791a3d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,10 +36,15 @@ import { CreateSessionDialog } from './components/CreateSessionDialog'; import { AddProjectDialog } from './components/AddProjectDialog'; import { useNavigationStore } from './stores/navigationStore'; import { initPostHog, capture, captureUnconditionally, posthog } from './services/posthog'; -import type { VersionUpdateInfo, PermissionInput } from './types/session'; +import type { VersionUpdateInfo } from './types/session'; import type { AnalyticsIdentity, TerminalShortcut } from './types/config'; import type { ResumableSession } from '../../shared/types/panels'; import type { Project } from './types/project'; +import type { + PanePermissionRequest, + PanePermissionResolvedEvent, + PanePermissionInput, +} from '../../shared/types/daemon'; import { isMac } from './utils/platformUtils'; // Stable empty array to avoid creating new references in render @@ -52,14 +57,6 @@ interface IPCResponse { error?: string; } -interface PermissionRequest { - id: string; - sessionId: string; - toolName: string; - input: PermissionInput; - timestamp: number; -} - function App() { const [isWelcomeOpen, setIsWelcomeOpen] = useState(false); const [isAnalyticsConsentOpen, setIsAnalyticsConsentOpen] = useState(false); @@ -67,7 +64,7 @@ function App() { const [isAboutOpen, setIsAboutOpen] = useState(false); const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); const [updateVersionInfo, setUpdateVersionInfo] = useState(null); - const [currentPermissionRequest, setCurrentPermissionRequest] = useState(null); + const [currentPermissionRequest, setCurrentPermissionRequest] = useState(null); const [isDiscordOpen, setIsDiscordOpen] = useState(false); const [hasCheckedWelcome, setHasCheckedWelcome] = useState(false); const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); @@ -511,19 +508,41 @@ function App() { checkResumableSessions(); }, [isLoaded, isAnalyticsConsentOpen]); + const loadNextPendingPermission = useCallback(async () => { + try { + const result = await API.permissions.getPending(); + if (result.success) { + setCurrentPermissionRequest(result.data?.[0] ?? null); + } else { + console.error('Failed to fetch pending permission requests:', result.error); + } + } catch (error) { + console.error('Failed to load pending permission requests:', error); + } + }, []); + useEffect(() => { - // Set up permission request listener - const handlePermissionRequest = (...args: unknown[]) => { - const request = args[0] as PermissionRequest; + if (!window.electronAPI?.events) { + return; + } + + const removePermissionRequest = window.electronAPI.events.onPermissionRequest((request: PanePermissionRequest) => { setCurrentPermissionRequest(request); - }; + }); + const removePermissionResolved = window.electronAPI.events.onPermissionResolved((event: PanePermissionResolvedEvent) => { + setCurrentPermissionRequest((currentRequest) => ( + currentRequest?.id === event.request.id ? null : currentRequest + )); + void loadNextPendingPermission(); + }); - window.electron?.on('permission:request', handlePermissionRequest); + void loadNextPendingPermission(); return () => { - window.electron?.off('permission:request', handlePermissionRequest); + removePermissionRequest(); + removePermissionResolved(); }; - }, []); + }, [loadNextPendingPermission]); useEffect(() => { // Set up version update listener @@ -561,17 +580,26 @@ function App() { setIsUpdateDialogOpen(true); }; - const handlePermissionResponse = async (requestId: string, behavior: 'allow' | 'deny', _updatedInput?: PermissionInput, message?: string) => { + const handlePermissionResponse = useCallback(async ( + requestId: string, + behavior: 'allow' | 'deny', + updatedInput?: PanePermissionInput, + message?: string, + ) => { try { - await API.permissions.respond(requestId, { - allow: behavior === 'allow', - reason: message + const result = await API.permissions.respond(requestId, { + behavior, + updatedInput, + message, }); - setCurrentPermissionRequest(null); + if (!result.success) { + throw new Error(result.error || 'Failed to respond to permission request'); + } + await loadNextPendingPermission(); } catch (error) { console.error('Failed to respond to permission request:', error); } - }; + }, [loadNextPendingPermission]); return ( diff --git a/frontend/src/components/PermissionDialog.tsx b/frontend/src/components/PermissionDialog.tsx index 781bf4a3..0583e346 100644 --- a/frontend/src/components/PermissionDialog.tsx +++ b/frontend/src/components/PermissionDialog.tsx @@ -1,20 +1,18 @@ import React, { useState, useEffect } from 'react'; import { Check, X, Shield, AlertTriangle, Code, Edit } from 'lucide-react'; +import type { PanePermissionRequest } from '../../../shared/types/daemon'; import { Modal, ModalHeader, ModalBody, ModalFooter } from './ui/Modal'; import { Button } from './ui/Button'; import { Textarea } from './ui/Textarea'; -interface PermissionRequest { - id: string; - sessionId: string; - toolName: string; - input: Record; - timestamp: number; -} - interface PermissionDialogProps { - request: PermissionRequest | null; - onRespond: (requestId: string, behavior: 'allow' | 'deny', updatedInput?: Record, message?: string) => void; + request: PanePermissionRequest | null; + onRespond: ( + requestId: string, + behavior: 'allow' | 'deny', + updatedInput?: PanePermissionRequest['input'], + message?: string, + ) => void; session?: { name: string }; } @@ -219,4 +217,4 @@ export const PermissionDialog: React.FC = ({ request, onR ); -}; \ No newline at end of file +}; diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index bf4b4a37..25927c33 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -11,6 +11,11 @@ import type { RemoteDaemonHostConfig, RemotePaneConnectionProfile, } from '../../../shared/types/remoteDaemon'; +import type { + PanePermissionRequest, + PanePermissionResolvedEvent, + PanePermissionResponse, +} from '../../../shared/types/daemon'; import type { ToolPanel } from '../../../shared/types/panels'; import type { CreateSessionRequest } from './session'; import type { DetectedProjectConfig } from '../../../shared/types/projectConfig'; @@ -22,11 +27,6 @@ interface LogEntry { source?: string; } -interface PermissionResponse { - allow: boolean; - reason?: string; -} - interface RendererDiagnosticPayload { kind: 'unhandledrejection' | 'error' | 'error-boundary'; message: string; @@ -255,8 +255,8 @@ interface ElectronAPI { // Permissions permissions: { - respond: (requestId: string, response: PermissionResponse) => Promise; - getPending: () => Promise; + respond: (requestId: string, response: PanePermissionResponse) => Promise; + getPending: () => Promise>; }; // Dashboard @@ -278,6 +278,8 @@ interface ElectronAPI { // Event listeners for real-time updates events: { + onPermissionRequest: (callback: (request: PanePermissionRequest) => void) => () => void; + onPermissionResolved: (callback: (event: PanePermissionResolvedEvent) => void) => () => void; onSessionCreated: (callback: (session: Session) => void) => () => void; onSessionUpdated: (callback: (session: Session) => void) => () => void; onSessionDeleted: (callback: (session: Session) => void) => () => void; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 0406dfc7..0939f7bc 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -9,6 +9,10 @@ import type { RemoteDaemonHostConfig, RemotePaneConnectionProfile, } from '../../../shared/types/remoteDaemon'; +import type { + PanePermissionRequest, + PanePermissionResponse, +} from '../../../shared/types/daemon'; // Type for IPC response // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic type parameter default for flexible API responses @@ -536,14 +540,14 @@ export class API { // Permissions static permissions = { - async respond(requestId: string, response: { allow: boolean; reason?: string }) { + async respond(requestId: string, response: PanePermissionResponse) { if (!isElectron()) throw new Error('Electron API not available'); return window.electronAPI.permissions.respond(requestId, response); }, async getPending() { if (!isElectron()) throw new Error('Electron API not available'); - return window.electronAPI.permissions.getPending(); + return window.electronAPI.permissions.getPending() as Promise>; }, }; diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 47cf47a1..c9608cf7 100644 --- a/main/src/core/importBoundary.test.ts +++ b/main/src/core/importBoundary.test.ts @@ -91,7 +91,7 @@ describe('daemon/client import boundary', () => { expect(source).not.toContain("from './daemon/daemonChannels'"); expect(source).toContain("ipcRenderer.invoke('daemon:invoke', channel, ...args)"); expect(source).not.toMatch( - /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, + /ipcRenderer\.invoke\('(sessions:|projects:|folders:|prompts:|resource-monitor:|panels:|terminal:|logs:|git:(cancel-status-for-project|clone-repo|commit|execute-project|file-status|get-github-remote|restore|revert)|permission:(getPending|respond)|file:(copy|delete|duplicate|exists|getPath|list|move|read|read-binary|read-project|readAtRevision|rename|resolveAbsolutePath|search|write|write-binary|write-project))/, ); }); diff --git a/main/src/daemon/permissionBroker.test.ts b/main/src/daemon/permissionBroker.test.ts new file mode 100644 index 00000000..10fd924d --- /dev/null +++ b/main/src/daemon/permissionBroker.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; +import type { + PanePermissionRequest, + PanePermissionResolvedEvent, +} from '../../../shared/types/daemon'; +import { PanePermissionBroker } from './permissionBroker'; + +function installTestRuntime(events: Array<{ channel: string; args: unknown[] }>): void { + setPaneRuntime({ + eventSink: { + send: (channel, ...args) => { + events.push({ channel, args }); + }, + }, + getConfigManager: () => ({} as never), + getPtyHostRuntime: () => null, + getWebviewContextMap: () => new Map(), + }); +} + +afterEach(() => { + PanePermissionBroker.resetForTests(); + resetPaneRuntimeForTests(); +}); + +describe('PanePermissionBroker', () => { + it('broadcasts permission requests and resolutions through the pane event sink', async () => { + const events: Array<{ channel: string; args: unknown[] }> = []; + installTestRuntime(events); + + const broker = PanePermissionBroker.getInstance(); + const permissionPromise = broker.requestPermission('session-1', 'Bash', { command: 'pwd' }); + + expect(events).toHaveLength(1); + expect(events[0]?.channel).toBe('permission:request'); + + const request = events[0]?.args[0] as PanePermissionRequest; + expect(broker.getPendingRequests()).toEqual([request]); + + broker.respondToRequest(request.id, { + behavior: 'allow', + updatedInput: { command: 'pwd', cwd: '/tmp' }, + message: 'approved', + }); + + await expect(permissionPromise).resolves.toEqual({ + behavior: 'allow', + updatedInput: { command: 'pwd', cwd: '/tmp' }, + message: 'approved', + }); + expect(broker.getPendingRequests()).toEqual([]); + + expect(events[1]).toEqual({ + channel: 'permission:resolved', + args: [{ + request, + response: { + behavior: 'allow', + updatedInput: { command: 'pwd', cwd: '/tmp' }, + message: 'approved', + }, + } satisfies PanePermissionResolvedEvent], + }); + }); + + it('clears session-scoped requests by denying them and emitting resolved events', async () => { + const events: Array<{ channel: string; args: unknown[] }> = []; + installTestRuntime(events); + + const broker = PanePermissionBroker.getInstance(); + const sessionOnePromise = broker.requestPermission('session-1', 'Write', { path: 'a.txt' }); + const sessionTwoPromise = broker.requestPermission('session-2', 'Write', { path: 'b.txt' }); + + broker.clearPendingRequests('session-1'); + + await expect(sessionOnePromise).resolves.toEqual({ + behavior: 'deny', + message: 'Session terminated', + }); + expect(broker.getPendingRequests()).toHaveLength(1); + expect(broker.getPendingRequests()[0]?.sessionId).toBe('session-2'); + + const resolvedEvent = events.find((event) => event.channel === 'permission:resolved'); + expect(resolvedEvent?.args[0]).toEqual({ + request: expect.objectContaining({ sessionId: 'session-1' }), + response: { + behavior: 'deny', + message: 'Session terminated', + }, + }); + + broker.respondToRequest(broker.getPendingRequests()[0]!.id, { + behavior: 'allow', + }); + await expect(sessionTwoPromise).resolves.toEqual({ behavior: 'allow' }); + }); +}); diff --git a/main/src/daemon/permissionBroker.ts b/main/src/daemon/permissionBroker.ts new file mode 100644 index 00000000..8b29ab56 --- /dev/null +++ b/main/src/daemon/permissionBroker.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'crypto'; +import { EventEmitter } from 'events'; +import { getPaneEventSink } from '../core/runtime'; +import type { + PanePermissionInput, + PanePermissionRequest, + PanePermissionResolvedEvent, + PanePermissionResponse, +} from '../../../shared/types/daemon'; + +export class PanePermissionBroker extends EventEmitter { + private readonly pendingRequests = new Map(); + private static instance: PanePermissionBroker | null = null; + + static getInstance(): PanePermissionBroker { + if (!PanePermissionBroker.instance) { + PanePermissionBroker.instance = new PanePermissionBroker(); + } + + return PanePermissionBroker.instance; + } + + static resetForTests(): void { + PanePermissionBroker.instance?.removeAllListeners(); + PanePermissionBroker.instance = null; + } + + getPendingRequests(): PanePermissionRequest[] { + return [...this.pendingRequests.values()]; + } + + async requestPermission( + sessionId: string, + toolName: string, + input: PanePermissionInput, + ): Promise { + const request: PanePermissionRequest = { + id: randomUUID(), + sessionId, + toolName, + input, + timestamp: Date.now(), + }; + + this.pendingRequests.set(request.id, request); + getPaneEventSink().send('permission:request', request); + + return new Promise((resolve) => { + this.once(`response:${request.id}`, (response: PanePermissionResponse) => { + resolve(response); + }); + }); + } + + respondToRequest(requestId: string, response: PanePermissionResponse): void { + const request = this.pendingRequests.get(requestId); + if (!request) { + throw new Error(`No pending permission request with id ${requestId}`); + } + + this.resolveRequest(request, response); + } + + clearPendingRequests(sessionId?: string): void { + const requests = [...this.pendingRequests.values()].filter((request) => ( + sessionId ? request.sessionId === sessionId : true + )); + + for (const request of requests) { + this.resolveRequest(request, { + behavior: 'deny', + message: sessionId ? 'Session terminated' : 'All requests cleared', + }); + } + } + + private resolveRequest(request: PanePermissionRequest, response: PanePermissionResponse): void { + this.pendingRequests.delete(request.id); + this.emit(`response:${request.id}`, response); + + const payload: PanePermissionResolvedEvent = { + request, + response, + }; + getPaneEventSink().send('permission:resolved', payload); + } +} diff --git a/main/src/daemon/server.test.ts b/main/src/daemon/server.test.ts index 32fa1c1a..86868d19 100644 --- a/main/src/daemon/server.test.ts +++ b/main/src/daemon/server.test.ts @@ -192,6 +192,66 @@ describe('PaneDaemonServer', () => { }); }); + it('forwards permission lifecycle events to daemon clients', async () => { + const registry = new PaneCommandRegistry(); + const server = new PaneDaemonServer(registry, createTempAppDirectory()); + activeServers.push(server); + await server.start(); + + const client = await connectClient(server); + server.getEventSink().send('permission:request', { + id: 'permission-1', + sessionId: 'session-1', + toolName: 'Bash', + input: { command: 'pwd' }, + timestamp: 1, + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'permission:request', + args: [{ + id: 'permission-1', + sessionId: 'session-1', + toolName: 'Bash', + input: { command: 'pwd' }, + timestamp: 1, + }], + }); + + server.getEventSink().send('permission:resolved', { + request: { + id: 'permission-1', + sessionId: 'session-1', + toolName: 'Bash', + input: { command: 'pwd' }, + timestamp: 1, + }, + response: { + behavior: 'allow', + message: 'approved', + }, + }); + + await expect(client.nextFrame()).resolves.toEqual({ + type: 'event', + channel: 'permission:resolved', + args: [{ + request: { + id: 'permission-1', + sessionId: 'session-1', + toolName: 'Bash', + input: { command: 'pwd' }, + timestamp: 1, + }, + response: { + behavior: 'allow', + message: 'approved', + }, + }], + }); + }); + it('forwards script state events to daemon clients', async () => { const registry = new PaneCommandRegistry(); const server = new PaneDaemonServer(registry, createTempAppDirectory()); diff --git a/main/src/daemon/server.ts b/main/src/daemon/server.ts index f5641c0b..39da6454 100644 --- a/main/src/daemon/server.ts +++ b/main/src/daemon/server.ts @@ -26,6 +26,8 @@ const DAEMON_EVENT_EXACT_CHANNELS = new Set([ 'git-status-loading', 'git-status-updated', 'logs:output', + 'permission:request', + 'permission:resolved', 'process:ended', 'project-script-changed', 'project-script-closing', diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 02302853..175eefb9 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -3,6 +3,7 @@ import { PaneCommandRegistry } from '../daemon/commandRegistry'; import { registerFileHandlers } from './file'; import { registerGitHandlers } from './git'; import { registerPanelHandlers } from './panels'; +import { registerPermissionHandlers } from './permissions'; import { registerProjectHandlers } from './project'; import { registerPromptHandlers } from './prompt'; import { registerScriptHandlers } from './script'; @@ -57,6 +58,11 @@ const PROMPT_CHANNELS = [ 'prompts:get-by-id', ] as const; +const PERMISSION_CHANNELS = [ + 'permission:getPending', + 'permission:respond', +] as const; + const FILE_CHANNELS = [ 'file:read', 'file:read-binary', @@ -259,6 +265,16 @@ describe('daemon registry IPC bindings', () => { expect(ipcMain.boundChannels.sort()).toEqual([...PROMPT_CHANNELS].sort()); }); + it('binds daemon-owned permission channels through the shared registry', () => { + const registry = new PaneCommandRegistry(); + const ipcMain = createIpcMainStub(); + + registerPermissionHandlers(ipcMain, createServicesStub(), registry); + + expect(registry.listChannels()).toEqual([...PERMISSION_CHANNELS].sort()); + expect(ipcMain.boundChannels.sort()).toEqual([...PERMISSION_CHANNELS].sort()); + }); + it('keeps file manager shell adapters outside the daemon registry surface', () => { const registry = new PaneCommandRegistry(); const ipcMain = createIpcMainStub(); diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index 4d562cfb..46fd55a2 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -24,6 +24,7 @@ import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; import { registerDaemonBridgeHandlers } from './daemon'; +import { registerPermissionHandlers } from './permissions'; import { PaneCommandRegistry } from '../daemon/commandRegistry'; @@ -36,6 +37,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerProjectHandlers(ipcMain, services, commandRegistry); registerConfigHandlers(ipcMain, services); registerDialogHandlers(ipcMain, services); + registerPermissionHandlers(ipcMain, services, commandRegistry); registerGitHandlers(ipcMain, services, commandRegistry); registerScriptHandlers(ipcMain, services, commandRegistry); registerPromptHandlers(ipcMain, services, commandRegistry); diff --git a/main/src/ipc/permissions.ts b/main/src/ipc/permissions.ts new file mode 100644 index 00000000..0d1a4a57 --- /dev/null +++ b/main/src/ipc/permissions.ts @@ -0,0 +1,35 @@ +import type { IpcMain } from 'electron'; +import type { PanePermissionResponse } from '../../../shared/types/daemon'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { PermissionManager } from '../services/permissionManager'; +import type { AppServices } from './types'; + +const DAEMON_PERMISSION_CHANNELS = [ + 'permission:getPending', + 'permission:respond', +] as const; + +export function registerPermissionHandlers( + ipcMain: IpcMain, + _services: AppServices, + commandRegistry: PaneCommandRegistry, +): void { + commandRegistry.register('permission:getPending', async () => ({ + success: true, + data: PermissionManager.getInstance().getPendingRequests(), + })); + + commandRegistry.register('permission:respond', async (requestId: string, response: PanePermissionResponse) => { + try { + PermissionManager.getInstance().respondToRequest(requestId, response); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + commandRegistry.bindChannels(ipcMain, DAEMON_PERMISSION_CHANNELS); +} diff --git a/main/src/preload.ts b/main/src/preload.ts index 41c091aa..daf0eb15 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -85,6 +85,25 @@ interface VersionInfo { releaseNotes?: string; } +interface PermissionRequest { + id: string; + sessionId: string; + toolName: string; + input: Record; + timestamp: number; +} + +interface PermissionResponse { + behavior: 'allow' | 'deny'; + updatedInput?: Record; + message?: string; +} + +interface PermissionResolvedEvent { + request: PermissionRequest; + response: PermissionResponse; +} + interface UpdaterInfo { version: string; releaseDate?: string; @@ -114,6 +133,8 @@ const DAEMON_OWNED_EXACT_CHANNELS = [ 'git:get-github-remote', 'git:restore', 'git:revert', + 'permission:getPending', + 'permission:respond', 'file:copy', 'file:delete', 'file:duplicate', @@ -608,8 +629,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // Permissions permissions: { - respond: (requestId: string, response: boolean | { approved: boolean; remember?: boolean }): Promise => invokeIpc('permission:respond', requestId, response), - getPending: (): Promise => invokeIpc('permission:getPending'), + respond: (requestId: string, response: PermissionResponse): Promise => invokeIpc('permission:respond', requestId, response), + getPending: (): Promise> => invokeIpc('permission:getPending'), }, // Stravu OAuth integration @@ -657,6 +678,16 @@ contextBridge.exposeInMainWorld('electronAPI', { // Event listeners for real-time updates events: { + onPermissionRequest: (callback: (request: PermissionRequest) => void) => { + const wrappedCallback = (_event: Electron.IpcRendererEvent, request: PermissionRequest) => callback(request); + ipcRenderer.on('permission:request', wrappedCallback); + return () => ipcRenderer.removeListener('permission:request', wrappedCallback); + }, + onPermissionResolved: (callback: (event: PermissionResolvedEvent) => void) => { + const wrappedCallback = (_event: Electron.IpcRendererEvent, event: PermissionResolvedEvent) => callback(event); + ipcRenderer.on('permission:resolved', wrappedCallback); + return () => ipcRenderer.removeListener('permission:resolved', wrappedCallback); + }, // Session events onSessionCreated: (callback: (session: Session) => void) => { const wrappedCallback = (_event: Electron.IpcRendererEvent, session: Session) => callback(session); @@ -1043,7 +1074,8 @@ contextBridge.exposeInMainWorld('electron', { invoke: (channel: string, ...args: unknown[]) => invokeIpc(channel, ...args), on: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ - 'permission:request' + 'permission:request', + 'permission:resolved', ]; if (validChannels.includes(channel)) { ipcRenderer.on(channel, (_event, ...args) => callback(...args)); @@ -1051,7 +1083,8 @@ contextBridge.exposeInMainWorld('electron', { }, off: (channel: string, callback: (...args: unknown[]) => void) => { const validChannels = [ - 'permission:request' + 'permission:request', + 'permission:resolved', ]; if (validChannels.includes(channel)) { ipcRenderer.removeListener(channel, callback); diff --git a/main/src/services/permissionManager.ts b/main/src/services/permissionManager.ts index 9ba3f00d..8bee4ef2 100644 --- a/main/src/services/permissionManager.ts +++ b/main/src/services/permissionManager.ts @@ -1,95 +1,25 @@ -import { EventEmitter } from 'events'; -import { ipcMain } from 'electron'; - -export interface PermissionRequest { - id: string; - sessionId: string; - toolName: string; - input: Record; - timestamp: number; -} - -export interface PermissionResponse { - behavior: 'allow' | 'deny'; - updatedInput?: Record; - message?: string; -} - -export class PermissionManager extends EventEmitter { - private pendingRequests: Map = new Map(); - private static instance: PermissionManager; - - private constructor() { - super(); - this.setupIpcHandlers(); - } - - static getInstance(): PermissionManager { - if (!PermissionManager.instance) { - PermissionManager.instance = new PermissionManager(); - } - return PermissionManager.instance; - } - - private setupIpcHandlers() { - ipcMain.handle('permission:respond', async (_, requestId: string, response: PermissionResponse) => { - const request = this.pendingRequests.get(requestId); - if (!request) { - throw new Error(`No pending permission request with id ${requestId}`); - } - - this.pendingRequests.delete(requestId); - this.emit(`response:${requestId}`, response); - return { success: true }; - }); - - ipcMain.handle('permission:getPending', async () => { - return Array.from(this.pendingRequests.values()); - }); +import { PanePermissionBroker } from '../daemon/permissionBroker'; +import type { + PanePermissionInput as PermissionInput, + PanePermissionRequest as PermissionRequest, + PanePermissionResponse as PermissionResponse, +} from '../../../shared/types/daemon'; + +export type { PermissionInput, PermissionRequest, PermissionResponse }; + +/** + * Legacy compatibility wrapper around the daemon-owned permission broker. + * + * Existing services still import `PermissionManager.getInstance()`. The broker + * now owns the state and event fanout so remote clients can approve requests + * without reaching into BrowserWindow globals. + */ +export class PermissionManager { + static getInstance(): PanePermissionBroker { + return PanePermissionBroker.getInstance(); } - async requestPermission(sessionId: string, toolName: string, input: Record): Promise { - const request: PermissionRequest = { - id: `${sessionId}-${Date.now()}-${Math.random()}`, - sessionId, - toolName, - input, - timestamp: Date.now() - }; - - this.pendingRequests.set(request.id, request); - - // Notify frontend about new permission request - const { getMainWindow } = await import('../index'); - const mainWindow = getMainWindow(); - if (mainWindow) { - console.log('[PermissionManager] Sending permission request to frontend:', request.id, request.toolName); - mainWindow.webContents.send('permission:request', request); - } else { - console.error('[PermissionManager] No main window available to send permission request!'); - } - - // Wait for response indefinitely (no timeout) - return new Promise((resolve, reject) => { - this.once(`response:${request.id}`, (response: PermissionResponse) => { - resolve(response); - }); - }); - } - - clearPendingRequests(sessionId?: string) { - if (sessionId) { - for (const [id, request] of this.pendingRequests.entries()) { - if (request.sessionId === sessionId) { - this.pendingRequests.delete(id); - this.emit(`response:${id}`, { behavior: 'deny', message: 'Session terminated' }); - } - } - } else { - for (const id of this.pendingRequests.keys()) { - this.emit(`response:${id}`, { behavior: 'deny', message: 'All requests cleared' }); - } - this.pendingRequests.clear(); - } + static resetForTests(): void { + PanePermissionBroker.resetForTests(); } -} \ No newline at end of file +} diff --git a/shared/types/daemon.ts b/shared/types/daemon.ts index e91ca3ac..ea8fc567 100644 --- a/shared/types/daemon.ts +++ b/shared/types/daemon.ts @@ -30,6 +30,27 @@ export interface PaneDaemonEventFrame { args: unknown[]; } +export type PanePermissionInput = Record; + +export interface PanePermissionRequest { + id: string; + sessionId: string; + toolName: string; + input: PanePermissionInput; + timestamp: number; +} + +export interface PanePermissionResponse { + behavior: 'allow' | 'deny'; + updatedInput?: PanePermissionInput; + message?: string; +} + +export interface PanePermissionResolvedEvent { + request: PanePermissionRequest; + response: PanePermissionResponse; +} + export type PaneDaemonResponseFrame = | PaneDaemonSuccessResponseFrame | PaneDaemonErrorResponseFrame; @@ -66,6 +87,8 @@ export const DAEMON_OWNED_EXACT_CHANNELS = [ 'git:get-github-remote', 'git:restore', 'git:revert', + 'permission:getPending', + 'permission:respond', 'file:copy', 'file:delete', 'file:duplicate', From a6ffe20938068f414c18df698a0c540c1d469eaa Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 17:02:36 -0700 Subject: [PATCH 033/111] test: cover daemon permission dialog smoke --- tests/electronApiMock.ts | 61 ++++++++++++++++++++++++++++++++++++++-- tests/smoke.spec.ts | 29 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index c3895fd0..5522fd89 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -4,7 +4,31 @@ export async function installElectronApiMock(page: Page) { await page.addInitScript(() => { const success = (data: unknown = null) => Promise.resolve({ success: true, data }); const unsubscribe = () => undefined; - const subscribe = () => unsubscribe; + const listeners = new Map void>>(); + const pendingPermissions: Array> = []; + + const subscribe = (channel: string, callback: (...args: unknown[]) => void) => { + const callbacks = listeners.get(channel) ?? new Set<(...args: unknown[]) => void>(); + callbacks.add(callback); + listeners.set(channel, callbacks); + return () => { + callbacks.delete(callback); + if (callbacks.size === 0) { + listeners.delete(channel); + } + }; + }; + + const emit = (channel: string, ...args: unknown[]) => { + const callbacks = listeners.get(channel); + if (!callbacks) { + return; + } + + for (const callback of callbacks) { + callback(...args); + } + }; const namespace = (overrides: Record = {}) => new Proxy(overrides, { @@ -17,7 +41,15 @@ export async function installElectronApiMock(page: Page) { }); const events = new Proxy({}, { - get: () => subscribe, + get: (_target, prop: string | symbol) => { + if (prop === 'onPermissionRequest') { + return (callback: (request: unknown) => void) => subscribe('permission:request', callback); + } + if (prop === 'onPermissionResolved') { + return (callback: (event: unknown) => void) => subscribe('permission:resolved', callback); + } + return () => unsubscribe; + }, }); const invoke = (channel: string) => { @@ -75,6 +107,17 @@ export async function installElectronApiMock(page: Page) { getSessionPanels: () => success([]), shouldAutoCreate: () => success(false), }), + permissions: namespace({ + getPending: () => success([...pendingPermissions]), + respond: (requestId: string, response: Record) => { + const index = pendingPermissions.findIndex((request) => request.id === requestId); + if (index >= 0) { + const [request] = pendingPermissions.splice(index, 1); + emit('permission:resolved', { request, response }); + } + return success(); + }, + }), projects: namespace({ getAll: () => success([]), getActive: () => success(null), @@ -114,9 +157,21 @@ export async function installElectronApiMock(page: Page) { configurable: true, value: { invoke, - on: subscribe, + on: (channel: string, callback: (...args: unknown[]) => void) => { + subscribe(channel, callback); + }, off: () => undefined, }, }); + + Object.defineProperty(window, '__paneTestElectronMock', { + configurable: true, + value: { + emitPermissionRequest(request: Record) { + pendingPermissions.push(request); + emit('permission:request', request); + }, + }, + }); }); } diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 9fc571a9..630aa0f4 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -69,4 +69,33 @@ test.describe('Smoke Tests', () => { // Small wait to ensure no errors are thrown await page.waitForTimeout(500); }); + + test('Permission dialog can approve a daemonized permission request', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + + await dismissStartupDialogs(page); + + await page.evaluate(() => { + const mock = (window as typeof window & { + __paneTestElectronMock?: { emitPermissionRequest: (request: Record) => void }; + }).__paneTestElectronMock; + + mock?.emitPermissionRequest({ + id: 'permission-1', + sessionId: 'session-1', + toolName: 'Bash', + input: { command: 'pwd' }, + timestamp: Date.now(), + }); + }); + + await expect(page.getByText('Permission Required')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Execute shell commands')).toBeVisible(); + await expect(page.getByText('Bash')).toBeVisible(); + + await page.getByRole('button', { name: 'Allow' }).click(); + + await expect(page.getByText('Permission Required')).toHaveCount(0); + await expect(page.getByText('Something went wrong')).toHaveCount(0); + }); }); From ae1fc517cc6d5aaf2b345829f5affdbbc3689793 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Sat, 16 May 2026 17:31:50 -0700 Subject: [PATCH 034/111] feat: add Electron remote daemon client adapter --- frontend/src/components/DetailPanel.tsx | 94 ++-- frontend/src/components/SessionView.tsx | 12 +- frontend/src/components/Settings.tsx | 412 ++++++++++++-- .../src/components/panels/TerminalPanel.tsx | 16 + .../components/panels/editor/FileEditor.tsx | 16 +- frontend/src/contexts/SessionContext.tsx | 7 +- frontend/src/hooks/useSessionView.ts | 10 + frontend/src/types/electron.d.ts | 5 + frontend/src/utils/api.ts | 21 + main/src/daemon/auth.ts | 6 +- .../daemon/client/remotePaneClient.test.ts | 239 ++++++++ main/src/daemon/client/remotePaneClient.ts | 528 ++++++++++++++++++ main/src/daemon/client/sseParser.test.ts | 39 ++ main/src/daemon/client/sseParser.ts | 70 +++ main/src/index.ts | 5 + main/src/ipc/daemon.test.ts | 32 +- main/src/ipc/daemon.ts | 17 +- main/src/ipc/index.ts | 20 +- main/src/ipc/remoteDaemon.test.ts | 69 ++- main/src/ipc/remoteDaemon.ts | 89 +++ main/src/preload.ts | 10 + shared/types/remoteDaemon.ts | 27 + 22 files changed, 1644 insertions(+), 100 deletions(-) create mode 100644 main/src/daemon/client/remotePaneClient.test.ts create mode 100644 main/src/daemon/client/remotePaneClient.ts create mode 100644 main/src/daemon/client/sseParser.test.ts create mode 100644 main/src/daemon/client/sseParser.ts diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx index 5322275b..5c91b150 100644 --- a/frontend/src/components/DetailPanel.tsx +++ b/frontend/src/components/DetailPanel.tsx @@ -78,6 +78,7 @@ function actionTooltip(action: { description?: string; disabled?: boolean; disab export function DetailPanel({ isVisible, width, height, onResize, mergeError, projectGitActions, orientation, isCollapsed, onToggleCollapse, onSwapLayout, terminalShortcuts, onCommitClick }: DetailPanelProps) { const sessionContext = useSession(); const immersiveMode = useNavigationStore(s => s.immersiveMode); + const remoteIdeTooltip = 'Open in IDE is only available in local mode. Switch this client back to the local runtime to use your desktop IDE.'; // Build IDE dropdown items, sending safe IDE keys (resolved to commands server-side) const ideItems = useMemo(() => { @@ -98,7 +99,7 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr if (!sessionContext) return null; - const { session, gitBranchActions, isMerging, gitCommands, onOpenIDEWithCommand, onConfigureIDE, onSetTracking, trackingBranch } = sessionContext; + const { session, gitBranchActions, isMerging, gitCommands, onOpenIDEWithCommand, onConfigureIDE, onSetTracking, trackingBranch, isRemoteMode } = sessionContext; const gitStatus = session.gitStatus; const isProject = !!session.isMainRepo; // Treat git as unavailable only when status has loaded but indicates failure. @@ -186,25 +187,35 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr {/* IDE button */} {onOpenIDEWithCommand && ( - - - - } - items={ideItems} - footer={ - - } - position="auto" - width="sm" - /> + + + ) : ( + + + + } + items={ideItems} + footer={ + + } + position="auto" + width="sm" + /> + ) )} {/* Terminal shortcut pills — inline with git actions */} @@ -333,24 +344,35 @@ export function DetailPanel({ isVisible, width, height, onResize, mergeError, pr )} {onOpenIDEWithCommand && ( - - - Open in IDE - - } - items={ideItems} - footer={ - - } - position="auto" - width="sm" - /> + isRemoteMode ? ( + + + + + + ) : ( + + + Open in IDE + + } + items={ideItems} + footer={ + + } + position="auto" + width="sm" + /> + ) )} diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index 71666d24..20c30fc1 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -53,6 +53,7 @@ export const SessionView = memo(() => { () => (config?.customCommands ?? []).filter(cmd => cmd?.name && cmd?.command), [config?.customCommands] ); + const isRemoteMode = config?.remoteDaemon?.client.mode === 'remote'; const deleteCustomCommand = useCallback((index: number) => { const existing = config?.customCommands ?? []; updateConfig({ customCommands: existing.filter((_, i) => i !== index) }).catch(() => {}); @@ -598,6 +599,13 @@ export const SessionView = memo(() => { const handleOpenIDEWithCommand = useCallback(async (ideKey?: string) => { if (!activeSession) return; + if (isRemoteMode) { + useErrorStore.getState().showError({ + title: 'Open IDE unavailable', + error: 'Open in IDE is only available in local mode. Switch this client back to the local runtime to use your desktop IDE.', + }); + return; + } try { const response = await API.sessions.openIDE(activeSession.id, ideKey); if (!response.success) { @@ -612,7 +620,7 @@ export const SessionView = memo(() => { error: error instanceof Error ? error.message : 'Unknown error occurred', }); } - }, [activeSession]); + }, [activeSession, isRemoteMode]); // Detail panel state const [detailVisible, setDetailVisible] = useState(() => { @@ -963,7 +971,7 @@ export const SessionView = memo(() => { return (
{/* SINGLE SessionProvider wraps everything */} - setShowProjectSettings(true)} onSetTracking={handleOpenSetTracking} trackingBranch={currentUpstream} configuredIDECommand={sessionProject?.open_ide_command}> + setShowProjectSettings(true)} onSetTracking={handleOpenSetTracking} trackingBranch={currentUpstream} configuredIDECommand={sessionProject?.open_ide_command} isRemoteMode={isRemoteMode}> {/* Tab bar at top */} ([]); const [terminalShortcuts, setTerminalShortcuts] = useState([]); const [worktreeFileSync, setWorktreeFileSync] = useState([]); + const [remoteDaemonConfig, setRemoteDaemonConfig] = useState(createDefaultRemoteDaemonConfig()); + const [remoteConnectionState, setRemoteConnectionState] = useState(createDefaultRemotePaneConnectionState()); + const [remoteHostConfigDraft, setRemoteHostConfigDraft] = useState(createDefaultRemoteDaemonConfig().host.config); + const [remotePairLabel, setRemotePairLabel] = useState(''); + const [remotePairBaseUrl, setRemotePairBaseUrl] = useState('http://127.0.0.1:42137'); + const [remoteCreatedToken, setRemoteCreatedToken] = useState(null); + const [remoteBusy, setRemoteBusy] = useState(false); const { updateSettings } = useNotifications(); const { theme, setTheme } = useTheme(); const { fetchConfig: refreshConfigStore } = useConfigStore(); + const refreshRemoteDaemonSettings = useCallback(async () => { + const [configResponse, connectionStateResponse] = await Promise.all([ + API.remoteDaemon.getConfig(), + API.remoteDaemon.getConnectionState(), + ]); + + if (configResponse.success && configResponse.data) { + setRemoteDaemonConfig(configResponse.data); + setRemoteHostConfigDraft(configResponse.data.host.config); + setRemotePairBaseUrl((currentValue) => ( + currentValue === 'http://127.0.0.1:42137' + ? `http://${configResponse.data!.host.config.listenHost}:${configResponse.data!.host.config.listenPort}` + : currentValue + )); + } + + if (connectionStateResponse.success && connectionStateResponse.data) { + setRemoteConnectionState(connectionStateResponse.data); + } + }, []); + + const fetchConfig = useCallback(async (currentPlatform?: string) => { + try { + const response = await API.config.get(); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to fetch config'); + } + const data = response.data; + setVerbose(data.verbose || false); + setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true + setDevMode(data.devMode || false); + setUsePtyHost(data.usePtyHost === true); + setInitialUsePtyHost(data.usePtyHost === true); + setClaudeExecutablePath(data.claudeExecutablePath || ''); + setEnableCommitFooter(data.enableCommitFooter !== false); // Default to true + setUiScale(data.uiScale || 1.0); + setTerminalFontFamily(data.terminalFontFamily || ''); + setTerminalFontSize(data.terminalFontSize || 14); + + // Load additional paths + const paths = data.additionalPaths || []; + setAdditionalPathsText(paths.join('\n')); + + // Load notification settings + if (data.notifications) { + setNotificationSettings(data.notifications); + // Update the useNotifications hook with loaded settings + updateSettings(data.notifications); + } + + // Load analytics settings + if (data.analytics) { + const enabled = data.analytics.enabled !== false; // Default to true + setAnalyticsEnabled(enabled); + setPreviousAnalyticsEnabled(enabled); + } + + // Fetch available shells on Windows + const platformToCheck = currentPlatform || platform; + if (platformToCheck === 'win32') { + const shellsResponse = await API.config.getAvailableShells(); + if (shellsResponse.success && shellsResponse.data) { + setAvailableShells(shellsResponse.data); + } + } + setPreferredShell(data.preferredShell || 'auto'); + + // Load terminal shortcuts + setTerminalShortcuts(data.terminalShortcuts ?? []); + + // Load worktree file sync entries + setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); + + await refreshRemoteDaemonSettings(); + } catch { + setError('Failed to load configuration'); + } + }, [platform, refreshRemoteDaemonSettings, updateSettings]); + useEffect(() => { if (isOpen) { // Get platform first, then fetch config (needed for Windows shell detection) @@ -96,6 +189,10 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { fetchConfig(p); }); + const unsubscribeRemoteConnectionState = window.electronAPI.remoteDaemon.onConnectionStateChanged((state) => { + setRemoteConnectionState(state); + }); + // Load system monospace fonts for the font picker window.electronAPI.config.getMonospaceFonts().then((result) => { if (result?.data && Array.isArray(result.data)) { @@ -137,63 +234,83 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { if (initialSection === 'terminal-shortcuts') { setActiveTab('shortcuts'); } + + return () => { + unsubscribeRemoteConnectionState(); + }; } - }, [isOpen, initialSection]); + }, [fetchConfig, initialSection, isOpen]); - const fetchConfig = async (currentPlatform?: string) => { + const runRemoteDaemonAction = async (action: () => Promise) => { + setRemoteBusy(true); + setError(null); try { - const response = await API.config.get(); - if (!response.success || !response.data) { - throw new Error(response.error || 'Failed to fetch config'); - } - const data = response.data; - setVerbose(data.verbose || false); - setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true - setDevMode(data.devMode || false); - setUsePtyHost(data.usePtyHost === true); - setInitialUsePtyHost(data.usePtyHost === true); - setClaudeExecutablePath(data.claudeExecutablePath || ''); - setEnableCommitFooter(data.enableCommitFooter !== false); // Default to true - setUiScale(data.uiScale || 1.0); - setTerminalFontFamily(data.terminalFontFamily || ''); - setTerminalFontSize(data.terminalFontSize || 14); - - // Load additional paths - const paths = data.additionalPaths || []; - setAdditionalPathsText(paths.join('\n')); + await action(); + await Promise.all([ + refreshRemoteDaemonSettings(), + refreshConfigStore(), + ]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Remote daemon action failed'); + } finally { + setRemoteBusy(false); + } + }; - // Load notification settings - if (data.notifications) { - setNotificationSettings(data.notifications); - // Update the useNotifications hook with loaded settings - updateSettings(data.notifications); + const handleSaveRemoteHostConfig = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateHostConfig(remoteHostConfigDraft); + if (!response.success) { + throw new Error(response.error || 'Failed to save remote daemon host config'); } + }); + }; - // Load analytics settings - if (data.analytics) { - const enabled = data.analytics.enabled !== false; // Default to true - setAnalyticsEnabled(enabled); - setPreviousAnalyticsEnabled(enabled); + const handleCreateRemoteConnectionPair = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.createConnectionPair({ + label: remotePairLabel, + baseUrl: remotePairBaseUrl, + }); + if (!response.success) { + throw new Error(response.error || 'Failed to create remote daemon connection pair'); } - // Fetch available shells on Windows - const platformToCheck = currentPlatform || platform; - if (platformToCheck === 'win32') { - const shellsResponse = await API.config.getAvailableShells(); - if (shellsResponse.success && shellsResponse.data) { - setAvailableShells(shellsResponse.data); - } + setRemotePairLabel(''); + setRemoteCreatedToken(response.data?.token ?? null); + }); + }; + + const handleUseRemoteProfile = async (profileId: string) => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateClientState({ + activeProfileId: profileId, + mode: 'remote', + }); + if (!response.success) { + throw new Error(response.error || 'Failed to connect to remote daemon profile'); } - setPreferredShell(data.preferredShell || 'auto'); + }); + }; - // Load terminal shortcuts - setTerminalShortcuts(data.terminalShortcuts ?? []); + const handleSwitchToLocalMode = async () => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.updateClientState({ + mode: 'local', + }); + if (!response.success) { + throw new Error(response.error || 'Failed to return to local mode'); + } + }); + }; - // Load worktree file sync entries - setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); - } catch { - setError('Failed to load configuration'); - } + const handleDeleteRemoteProfile = async (profileId: string) => { + await runRemoteDaemonAction(async () => { + const response = await API.remoteDaemon.deleteConnectionProfile(profileId); + if (!response.success) { + throw new Error(response.error || 'Failed to delete remote daemon connection profile'); + } + }); }; const handleAutoRenameToggle = async (checked: boolean) => { @@ -816,6 +933,207 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { + } + defaultExpanded={false} + > + } + > +
+
+

+ {remoteConnectionState.mode === 'remote' ? 'Remote mode' : 'Local mode'} +

+

+ Status: {remoteConnectionState.status} + {remoteConnectionState.activeProfileLabel ? ` via ${remoteConnectionState.activeProfileLabel}` : ''} +

+ {remoteConnectionState.lastError && ( +

{remoteConnectionState.lastError}

+ )} +
+ +
+
+ + } + > +
+ setRemoteHostConfigDraft({ + ...remoteHostConfigDraft, + enabled: e.target.checked, + })} + /> +
+ setRemoteHostConfigDraft({ + ...remoteHostConfigDraft, + listenHost: e.target.value, + })} + placeholder="127.0.0.1" + fullWidth + /> + setRemoteHostConfigDraft({ + ...remoteHostConfigDraft, + listenPort: Number.parseInt(e.target.value, 10) || 42137, + })} + placeholder="42137" + fullWidth + /> +
+ setRemoteHostConfigDraft({ + ...remoteHostConfigDraft, + pairingRequired: e.target.checked, + })} + /> + setRemoteHostConfigDraft({ + ...remoteHostConfigDraft, + allowInsecureHttpOnLoopback: e.target.checked, + })} + /> +
+

+ Paired remote clients on this machine: {remoteDaemonConfig.host.clients.length} +

+ +
+
+
+ + } + > +
+ setRemotePairLabel(e.target.value)} + placeholder="Mac mini in office" + fullWidth + /> + setRemotePairBaseUrl(e.target.value)} + placeholder="http://127.0.0.1:42137" + fullWidth + /> +
+ +
+ {remoteCreatedToken && ( +
+

Latest generated remote token

+