diff --git a/main/src/core/importBoundary.test.ts b/main/src/core/importBoundary.test.ts index 942b870..4881387 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 0000000..32fa1c1 --- /dev/null +++ b/main/src/daemon/server.test.ts @@ -0,0 +1,406 @@ +import fs from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } 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('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('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('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(), 'linux'); + activeServers.push(server); + await server.start(); + + 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 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', + 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); + 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; + } + + 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); + }); + + 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); + }); + + 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/server.ts b/main/src/daemon/server.ts new file mode 100644 index 0000000..096d987 --- /dev/null +++ b/main/src/daemon/server.ts @@ -0,0 +1,355 @@ +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', + '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 { + 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; + 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(); + 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] of this.clients) { + this.writeFrame(clientId, encodedFrame); + } + }, + }; + + 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') { + 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') { + throw new Error(`Pane daemon server is already listening on ${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); + if (this.endpoint.transport === 'unix') { + fs.chmodSync(this.endpoint.path, UNIX_SOCKET_FILE_MODE); + } + 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(), + pendingFrames: [], + pendingBytes: 0, + waitingForDrain: false, + }; + + 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(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); + }); + + 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); + client.pendingFrames.length = 0; + client.pendingBytes = 0; + client.waitingForDrain = false; + if (!client.socket.destroyed) { + client.socket.destroy(); + } + } + + private async handleRequest(clientId: string, frame: PaneDaemonRequestFrame): Promise { + const response = await this.buildResponseFrame(frame); + const client = this.clients.get(clientId); + + if (!client || client.socket.destroyed) { + return; + } + + this.writeFrame(clientId, encodePaneDaemonFrame(response)); + } + + 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 = client.socket.write(encodedFrame); + if (!accepted) { + 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 { + 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, + }, + }; + } + } +} + +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); + }); + }); +} diff --git a/main/src/daemon/socketPath.test.ts b/main/src/daemon/socketPath.test.ts index 6877ca9..8254351 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 76a90c0..b39ecf5 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 { diff --git a/main/src/index.ts b/main/src/index.ts index 6792e85..ac59914 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();