/** * PtyTerminalService — manages node-pty terminal instances. * * Provides PTY spawning, IO, and lifecycle management for the embedded terminal. * Events (data, exit) are forwarded to the renderer via mainWindow.webContents.send(). */ import crypto from 'node:crypto'; import { getHomeDir } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import type { PtySpawnOptions } from '@shared/types/terminal'; import type { BrowserWindow } from 'electron'; const logger = createLogger('PtyTerminalService'); // Graceful import: node-pty is a native addon that may not be available // if electron-rebuild was not run or native build tools are missing. import type { IPty } from 'node-pty'; import type * as NodePty from 'node-pty'; type NodePtyModule = typeof NodePty; let nodePty: NodePtyModule | null = null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon nodePty = require('node-pty') as NodePtyModule; } catch { logger.warn('node-pty not available — terminal features disabled'); } export class PtyTerminalService { private ptys = new Map(); private mainWindow: BrowserWindow | null = null; setMainWindow(window: BrowserWindow | null): void { this.mainWindow = window; } /** * Spawn a new PTY process. * @returns Unique PTY ID for subsequent write/resize/kill calls. * @throws If node-pty native module is not available. */ async spawn(options?: PtySpawnOptions): Promise { if (!nodePty) { throw new Error( 'Terminal not available: node-pty native module not found. Run: pnpm install' ); } const id = crypto.randomUUID(); const { env } = await buildProviderAwareCliEnv({ env: options?.env, connectionMode: 'augment', allowStoredApiKeyDecryption: false, }); const shell = options?.command ?? (process.platform === 'win32' ? (env.COMSPEC ?? process.env.COMSPEC ?? 'powershell.exe') : (env.SHELL ?? process.env.SHELL ?? '/bin/bash')); const home = getHomeDir(); const pty = nodePty.spawn(shell, options?.args ?? [], { name: 'xterm-256color', cols: options?.cols ?? 80, rows: options?.rows ?? 24, cwd: options?.cwd ?? home, env: env as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); pty.onExit(({ exitCode }) => { this.send(TERMINAL_EXIT, id, exitCode); this.ptys.delete(id); }); this.ptys.set(id, pty); logger.info(`PTY spawned: ${id} (${shell})`); return id; } write(id: string, data: string): void { this.ptys.get(id)?.write(data); } resize(id: string, cols: number, rows: number): void { this.ptys.get(id)?.resize(cols, rows); } kill(id: string): void { const pty = this.ptys.get(id); if (pty) { pty.kill(); this.ptys.delete(id); logger.info(`PTY killed: ${id}`); } } /** Kill all PTY processes. Called on app shutdown. */ killAll(): void { const count = this.ptys.size; if (count > 0) { logger.info(`Killing ${count} PTY processes on shutdown`); } this.ptys.forEach((pty) => pty.kill()); this.ptys.clear(); } private send(channel: string, ...args: unknown[]): void { safeSendToRenderer(this.mainWindow, channel, ...args); } }