Files
automaker/apps/server/src/services/terminal-service.ts
Dhanush Santosh 88864ad6bc feature/custom terminal configs (#717)
* feat(terminal): Add core infrastructure for custom terminal configurations

- Add TerminalConfig types to settings schema (global & project-specific)
- Create RC generator with hex-to-xterm-256 color mapping
- Create RC file manager for .automaker/terminal/ directory
- Add terminal theme color data (40 themes) to platform package
- Integrate terminal config injection into TerminalService
- Support bash, zsh, and sh with proper env var injection (BASH_ENV, ZDOTDIR, ENV)
- Add onThemeChange hook for theme synchronization

Part of custom terminal configurations feature implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Wire terminal service with settings service

- Pass SettingsService to TerminalService constructor
- Initialize terminal service with settings service dependency
- Enable terminal config injection to work with actual settings

This completes Steps 1-4 of the terminal configuration plan:
- RC Generator (color mapping, prompt formats)
- RC File Manager (file I/O, atomic writes)
- Settings Schema (GlobalSettings + ProjectSettings)
- Terminal Service Integration (env var injection)

Next steps: Settings UI and theme change hooks.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Add Settings UI and theme change synchronization

Complete Steps 5 & 6 of terminal configuration implementation:

Settings UI Components:
- Add PromptPreview component with live theme-aware rendering
- Add TerminalConfigSection with comprehensive controls:
  * Enable/disable toggle with confirmation dialog
  * Custom prompt toggle
  * Prompt format selector (4 formats)
  * Git branch/status toggles
  * Custom aliases textarea
  * Custom env vars key-value editor with validation
  * Info box explaining behavior
- Integrate into existing TerminalSection

Theme Change Hook:
- Add theme detection in update-global settings route
- Regenerate RC files for all projects when theme changes
- Skip projects with terminal config disabled
- Error handling with per-project logging
- Inject terminal service with settings service dependency

This completes the full terminal configuration feature:
✓ RC Generator (color mapping, prompts)
✓ RC File Manager (file I/O, versioning)
✓ Settings Schema (types, defaults)
✓ Terminal Service Integration (env vars, PTY spawn)
✓ Settings UI (comprehensive controls, preview)
✓ Theme Synchronization (automatic RC regeneration)

New terminals will use custom prompts matching app theme.
Existing terminals unaffected. User RC files preserved.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Add error handling and explicit field mapping for terminal config

- Add try-catch block to handleToggleEnabled
- Explicitly set all required terminalConfig fields
- Add console logging for debugging
- Show error toast if update fails
- Include rcFileVersion: 1 in config object

This should fix the issue where the toggle doesn't enable after
clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query mutation hook for settings updates

The issue was that `updateGlobalSettings` doesn't exist in the app store.
The correct pattern is to use the `useUpdateGlobalSettings` hook from
use-settings-mutations.ts, which is a React Query mutation.

Changes:
- Import useUpdateGlobalSettings from mutations hook
- Use mutation.mutate() instead of direct function call
- Add proper onSuccess/onError callbacks
- Remove async/await pattern (React Query handles this)

This fixes the toggle not enabling after clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query hook for globalSettings instead of store

The root cause: Component was reading globalSettings from the app store,
which doesn't update reactively when the mutation completes.

Solution: Use useGlobalSettings() React Query hook which:
- Automatically refetches when the mutation invalidates the cache
- Triggers re-render with updated data
- Makes the toggle reflect the new state

Now the flow is:
1. User clicks toggle → confirmation dialog
2. Click OK → mutation.mutate() called
3. Mutation succeeds → invalidates queryKeys.settings.global()
4. Query refetches → component re-renders with new globalSettings
5. Toggle shows enabled state ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* debug(terminal): Add detailed logging for terminal config application

Add logging to track:
- When terminal config check happens
- CWD being used
- Global and project enabled states
- Effective enabled state

This will help diagnose why RC files aren't being generated
when opening terminals in Automaker.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix terminal rc updates and bash rcfile loading

* feat(terminal): add banner on shell start

* feat(terminal): colorize banner per theme

* chore(terminal): bump rc version for banner colors

* feat(terminal): match banner colors to launcher

* feat(terminal): add prompt customization controls

* feat: integrate oh-my-posh prompt themes

* fix: resolve oh-my-posh theme path

* fix: correct oh-my-posh config invocation

* docs: add terminal theme screenshot

* fix: address review feedback and stabilize e2e test

* ui: split terminal config into separate card

* fix: enable cross-platform Warp terminal detection

- Remove macOS-only platform restriction for Warp
- Add Linux CLI alias 'warp-terminal' (primary on Linux)
- Add CLI launch handler using --cwd flag
- Fixes issue where Warp was not detected on Linux systems

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:34:33 +05:30

936 lines
31 KiB
TypeScript

/**
* Terminal Service
*
* Manages PTY (pseudo-terminal) sessions using node-pty.
* Supports cross-platform shell detection including WSL.
*/
import * as pty from 'node-pty';
import { EventEmitter } from 'events';
import * as os from 'os';
import * as path from 'path';
// secureFs is used for user-controllable paths (working directory validation)
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js';
import { createLogger } from '@automaker/utils';
import type { SettingsService } from './settings-service.js';
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
import {
getRcFilePath,
getTerminalDir,
ensureRcFilesUpToDate,
type TerminalConfig,
} from '@automaker/platform';
const logger = createLogger('Terminal');
// System paths module handles shell binary checks and WSL detection
// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing
import {
systemPathExists,
systemPathReadFileSync,
getWslVersionPath,
getShellPaths,
} from '@automaker/platform';
const BASH_LOGIN_ARG = '--login';
const BASH_RCFILE_ARG = '--rcfile';
const SHELL_NAME_BASH = 'bash';
const SHELL_NAME_ZSH = 'zsh';
const SHELL_NAME_SH = 'sh';
const DEFAULT_SHOW_USER_HOST = true;
const DEFAULT_SHOW_PATH = true;
const DEFAULT_SHOW_TIME = false;
const DEFAULT_SHOW_EXIT_STATUS = false;
const DEFAULT_PATH_DEPTH = 0;
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
const DEFAULT_CUSTOM_PROMPT = true;
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
const DEFAULT_SHOW_GIT_BRANCH = true;
const DEFAULT_SHOW_GIT_STATUS = true;
const DEFAULT_CUSTOM_ALIASES = '';
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
const PROMPT_THEME_CUSTOM = 'custom';
const PROMPT_THEME_PREFIX = 'omp-';
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Session limit constants - shared with routes/settings.ts
export const MIN_MAX_SESSIONS = 1;
export const MAX_MAX_SESSIONS = 1000;
// Maximum number of concurrent terminal sessions
// Can be overridden via TERMINAL_MAX_SESSIONS environment variable
// Default set to 1000 - effectively unlimited for most use cases
let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
// Throttle output to prevent overwhelming WebSocket under heavy load
// Using 4ms for responsive input feedback while still preventing flood
// Note: 16ms caused perceived input lag, especially with backspace
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
const sanitizedArgs: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === BASH_LOGIN_ARG) {
continue;
}
if (arg === BASH_RCFILE_ARG) {
index += 1;
continue;
}
sanitizedArgs.push(arg);
}
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
return sanitizedArgs;
}
function normalizePathStyle(
pathStyle: TerminalConfig['pathStyle'] | undefined
): TerminalConfig['pathStyle'] {
if (pathStyle === 'short' || pathStyle === 'basename') {
return pathStyle;
}
return DEFAULT_PATH_STYLE;
}
function normalizePathDepth(pathDepth: number | undefined): number {
const depth =
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
}
function getShellBasename(shellPath: string): string {
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
}
function getShellArgsForPath(shellPath: string): string[] {
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
return [];
}
if (shellName === SHELL_NAME_SH) {
return [];
}
return [BASH_LOGIN_ARG];
}
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
return null;
}
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
}
return null;
}
function buildEffectiveTerminalConfig(
globalTerminalConfig: TerminalConfig | undefined,
projectTerminalConfig: Partial<TerminalConfig> | undefined
): TerminalConfig {
const mergedEnvVars = {
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
};
return {
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
showGitBranch:
projectTerminalConfig?.showGitBranch ??
globalTerminalConfig?.showGitBranch ??
DEFAULT_SHOW_GIT_BRANCH,
showGitStatus:
projectTerminalConfig?.showGitStatus ??
globalTerminalConfig?.showGitStatus ??
DEFAULT_SHOW_GIT_STATUS,
showUserHost:
projectTerminalConfig?.showUserHost ??
globalTerminalConfig?.showUserHost ??
DEFAULT_SHOW_USER_HOST,
showPath:
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
pathStyle: normalizePathStyle(
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
),
pathDepth: normalizePathDepth(
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
),
showTime:
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
showExitStatus:
projectTerminalConfig?.showExitStatus ??
globalTerminalConfig?.showExitStatus ??
DEFAULT_SHOW_EXIT_STATUS,
customAliases:
projectTerminalConfig?.customAliases ??
globalTerminalConfig?.customAliases ??
DEFAULT_CUSTOM_ALIASES,
customEnvVars: mergedEnvVars,
rcFileVersion: globalTerminalConfig?.rcFileVersion,
};
}
export interface TerminalSession {
id: string;
pty: pty.IPty;
cwd: string;
createdAt: Date;
shell: string;
scrollbackBuffer: string; // Store recent output for replay on reconnect
outputBuffer: string; // Pending output to be flushed
flushTimeout: NodeJS.Timeout | null; // Throttle timer
resizeInProgress: boolean; // Flag to suppress scrollback during resize
resizeDebounceTimeout: NodeJS.Timeout | null; // Resize settle timer
}
export interface TerminalOptions {
cwd?: string;
shell?: string;
cols?: number;
rows?: number;
env?: Record<string, string>;
}
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === 'win32';
// On Windows, ConPTY requires AttachConsole which fails in Electron/service mode
// Detect Electron by checking for electron-specific env vars or process properties
private isElectron =
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
private settingsService: SettingsService | null = null;
constructor(settingsService?: SettingsService) {
super();
this.settingsService = settingsService || null;
}
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments.
* On Unix-like systems (macOS, Linux), we can specify the signal.
*
* @param ptyProcess - The PTY process to kill
* @param signal - The signal to send on Unix-like systems (default: 'SIGTERM')
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/**
* Detect the best shell for the current platform
* Uses getShellPaths() to iterate through allowed shell paths
*/
detectShell(): { shell: string; args: string[] } {
const platform = os.platform();
const shellPaths = getShellPaths();
// Check if running in WSL - prefer user's shell or bash with --login
if (platform === 'linux' && this.isWSL()) {
const userShell = process.env.SHELL;
if (userShell) {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
}
}
}
}
// Fall back to first available POSIX shell
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed, continue
}
}
return { shell: '/bin/bash', args: ['--login'] };
}
// For all platforms: first try user's shell if set
const userShell = process.env.SHELL;
if (userShell && platform !== 'win32') {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
}
}
}
}
// Iterate through allowed shell paths and return first existing one
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed or doesn't exist, continue to next
}
}
// Ultimate fallbacks based on platform
if (platform === 'win32') {
return { shell: 'cmd.exe', args: [] };
}
return { shell: '/bin/sh', args: [] };
}
/**
* Detect if running inside WSL (Windows Subsystem for Linux)
*/
isWSL(): boolean {
try {
// Check /proc/version for Microsoft/WSL indicators
const wslVersionPath = getWslVersionPath();
if (systemPathExists(wslVersionPath)) {
const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase();
return version.includes('microsoft') || version.includes('wsl');
}
// Check for WSL environment variable
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
return true;
}
} catch {
// Ignore errors
}
return false;
}
/**
* Get platform info for the client
*/
getPlatformInfo(): {
platform: string;
isWSL: boolean;
defaultShell: string;
arch: string;
} {
const { shell } = this.detectShell();
return {
platform: os.platform(),
isWSL: this.isWSL(),
defaultShell: shell,
arch: os.arch(),
};
}
/**
* Validate and resolve a working directory path
* Includes basic sanitization against null bytes and path normalization
* Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths
*/
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
const homeDir = os.homedir();
// If no cwd requested, use home
if (!requestedCwd) {
return homeDir;
}
// Clean up the path
let cwd = requestedCwd.trim();
// Reject paths with null bytes (could bypass path checks)
if (cwd.includes('\0')) {
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
return homeDir;
}
// Fix double slashes at start (but not for Windows UNC paths)
if (cwd.startsWith('//') && !cwd.startsWith('//wsl')) {
cwd = cwd.slice(1);
}
// Normalize the path to resolve . and .. segments
// Skip normalization for WSL UNC paths as path.resolve would break them
if (!cwd.startsWith('//wsl')) {
cwd = path.resolve(cwd);
}
// Check if path exists and is a directory
// Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary
// This prevents terminals from being opened in directories outside the allowed workspace
try {
const statResult = await secureFs.stat(cwd);
if (statResult.isDirectory()) {
return cwd;
}
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
logger.warn(`Working directory does not exist or not allowed: ${cwd}, falling back to home`);
return homeDir;
}
}
/**
* Get current session count
*/
getSessionCount(): number {
return this.sessions.size;
}
/**
* Get maximum allowed sessions
*/
getMaxSessions(): number {
return maxSessions;
}
/**
* Set maximum allowed sessions (can be called dynamically)
*/
setMaxSessions(limit: number): void {
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
maxSessions = limit;
logger.info(`Max sessions limit updated to ${limit}`);
}
}
/**
* Create a new terminal session
* Returns null if the maximum session limit has been reached
*/
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Check session limit
if (this.sessions.size >= maxSessions) {
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
return null;
}
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
// Validate and resolve working directory
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
const cwd = await this.resolveWorkingDirectory(options.cwd);
// Build environment with some useful defaults
// These settings ensure consistent terminal behavior across platforms
// First, create a clean copy of process.env excluding Automaker-specific variables
// that could pollute user shells (e.g., PORT would affect Next.js/other dev servers)
const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH'];
const cleanEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !automakerEnvVars.includes(key)) {
cleanEnv[key] = value;
}
}
// Terminal config injection (custom prompts, themes)
const terminalConfigEnv: Record<string, string> = {};
if (this.settingsService) {
try {
logger.info(
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
);
const globalSettings = await this.settingsService.getGlobalSettings();
const projectSettings = options.cwd
? await this.settingsService.getProjectSettings(options.cwd)
: null;
const globalTerminalConfig = globalSettings?.terminalConfig;
const projectTerminalConfig = projectSettings?.terminalConfig;
const effectiveConfig = buildEffectiveTerminalConfig(
globalTerminalConfig,
projectTerminalConfig
);
logger.info(
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
);
logger.info(
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
);
if (effectiveConfig.enabled && globalTerminalConfig) {
const currentTheme = globalSettings?.theme || 'dark';
const themeColors = getTerminalThemeColors(currentTheme);
const allThemes = getAllTerminalThemes();
const promptTheme =
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
const ompThemeName = resolveOmpThemeName(promptTheme);
// Ensure RC files are up to date
await ensureRcFilesUpToDate(
options.cwd || cwd,
currentTheme,
effectiveConfig,
themeColors,
allThemes
);
// Set shell-specific env vars
const shellName = getShellBasename(shell).toLowerCase();
if (ompThemeName && effectiveConfig.customPrompt) {
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
}
if (shellName.includes(SHELL_NAME_BASH)) {
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
terminalConfigEnv.BASH_ENV = bashRcFilePath;
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
} else if (shellName.includes(SHELL_NAME_ZSH)) {
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
} else if (shellName === SHELL_NAME_SH) {
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
}
// Add custom env vars from config
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
logger.info(
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
);
}
} catch (error) {
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
}
}
const env: Record<string, string> = {
...cleanEnv,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: 'automaker-terminal',
// Ensure proper locale for character handling
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
...options.env,
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
};
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: 'xterm-256color',
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
};
// On Windows, always use winpty instead of ConPTY
// ConPTY requires AttachConsole which fails in many contexts:
// - Electron apps without a console
// - VS Code integrated terminal
// - Spawned from other applications
// The error happens in a subprocess so we can't catch it - must proactively disable
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
logger.info(
`[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)`
);
}
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
// Check for Windows ConPTY-specific errors
if (this.isWindows && errorMessage.includes('AttachConsole failed')) {
// ConPTY failed - try winpty fallback
if (!this.useConptyFallback) {
logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`);
this.useConptyFallback = true;
try {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`);
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage);
return null;
}
} else {
logger.error(`[createSession] PTY spawn failed:`, errorMessage);
return null;
}
}
const session: TerminalSession = {
id,
pty: ptyProcess,
cwd,
createdAt: new Date(),
shell,
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
resizeInProgress: false,
resizeDebounceTimeout: null,
};
this.sessions.set(id, session);
// Flush buffered output to clients (throttled)
const flushOutput = () => {
if (session.outputBuffer.length === 0) return;
// Send in batches if buffer is large
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
// Schedule another flush for remaining data
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = '';
session.flushTimeout = null;
}
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit('data', id, dataToSend);
};
// Forward data events with throttling
ptyProcess.onData((data) => {
// Skip ALL output during resize/reconnect to prevent prompt redraw duplication
// This drops both scrollback AND live output during the suppression window
// Without this, prompt redraws from SIGWINCH go to live clients causing duplicates
if (session.resizeInProgress) {
return;
}
// Append to scrollback buffer
session.scrollbackBuffer += data;
// Trim if too large (keep the most recent data)
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// Buffer output for throttled live delivery
session.outputBuffer += data;
// Schedule flush if not already scheduled
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
}
});
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
const exitMessage =
exitCode === undefined || exitCode === null
? 'Session terminated'
: `Session exited with code ${exitCode}`;
logger.info(`${exitMessage} (${id})`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode);
});
logger.info(`Session ${id} created successfully`);
return session;
}
/**
* Write data to a terminal session
*/
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
return true;
}
/**
* Resize a terminal session
* @param suppressOutput - If true, suppress output during resize to prevent duplicate prompts.
* Should be false for the initial resize so the first prompt isn't dropped.
*/
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
logger.warn(`Session ${sessionId} not found for resize`);
return false;
}
try {
// Only suppress output on subsequent resizes, not the initial one
// This prevents the shell's first prompt from being dropped
if (suppressOutput) {
session.resizeInProgress = true;
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
}
}
session.pty.resize(cols, rows);
// Clear resize flag after a delay (allow prompt to settle)
// 150ms is enough for most prompts - longer causes sluggish feel
if (suppressOutput) {
session.resizeDebounceTimeout = setTimeout(() => {
session.resizeInProgress = false;
session.resizeDebounceTimeout = null;
}, 150);
}
return true;
} catch (error) {
logger.error(`Error resizing session ${sessionId}:`, error);
session.resizeInProgress = false; // Clear flag on error
return false;
}
}
/**
* Kill a terminal session
* Attempts graceful SIGTERM first, then SIGKILL after 1 second if still alive
*/
killSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
// Clean up resize debounce timeout
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
session.resizeDebounceTimeout = null;
}
// First try graceful SIGTERM to allow process cleanup
// On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals
logger.info(`Session ${sessionId} sending SIGTERM`);
this.killPtyProcess(session.pty, 'SIGTERM');
// Schedule SIGKILL fallback if process doesn't exit gracefully
// The onExit handler will remove session from map when it actually exits
setTimeout(() => {
if (this.sessions.has(sessionId)) {
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try {
this.killPtyProcess(session.pty, 'SIGKILL');
} catch {
// Process may have already exited
}
// Force remove from map if still present
this.sessions.delete(sessionId);
}
}, 1000);
logger.info(`Session ${sessionId} kill initiated`);
return true;
} catch (error) {
logger.error(`Error killing session ${sessionId}:`, error);
// Still try to remove from map even if kill fails
this.sessions.delete(sessionId);
return false;
}
}
/**
* Get a session by ID
*/
getSession(sessionId: string): TerminalSession | undefined {
return this.sessions.get(sessionId);
}
/**
* Get scrollback buffer for a session (for replay on reconnect)
*/
getScrollback(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
return session?.scrollbackBuffer || null;
}
/**
* Get scrollback buffer and clear pending output buffer to prevent duplicates
* Call this when establishing a new WebSocket connection
* This prevents data that's already in scrollback from being sent again via data callback
*/
getScrollbackAndClearPending(sessionId: string): string | null {
const session = this.sessions.get(sessionId);
if (!session) return null;
// Clear any pending output that hasn't been flushed yet
// This data is already in scrollbackBuffer
session.outputBuffer = '';
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
// NOTE: Don't set resizeInProgress here - it causes blank terminals
// if the shell hasn't output its prompt yet when WebSocket connects.
// The resize() method handles suppression during actual resize events.
return session.scrollbackBuffer || null;
}
/**
* Get all active sessions
*/
getAllSessions(): Array<{
id: string;
cwd: string;
createdAt: Date;
shell: string;
}> {
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
cwd: s.cwd,
createdAt: s.createdAt,
shell: s.shell,
}));
}
/**
* Subscribe to data events
*/
onData(callback: DataCallback): () => void {
this.dataCallbacks.add(callback);
return () => this.dataCallbacks.delete(callback);
}
/**
* Subscribe to exit events
*/
onExit(callback: ExitCallback): () => void {
this.exitCallbacks.add(callback);
return () => this.exitCallbacks.delete(callback);
}
/**
* Handle theme change - regenerate RC files with new theme colors
*/
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
if (!this.settingsService) {
logger.warn('[onThemeChange] SettingsService not available');
return;
}
try {
const globalSettings = await this.settingsService.getGlobalSettings();
const terminalConfig = globalSettings?.terminalConfig;
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
const projectTerminalConfig = projectSettings?.terminalConfig;
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
if (effectiveConfig.enabled && terminalConfig) {
const themeColors = getTerminalThemeColors(
newTheme as import('@automaker/types').ThemeMode
);
const allThemes = getAllTerminalThemes();
// Regenerate RC files with new theme
await ensureRcFilesUpToDate(
projectPath,
newTheme as import('@automaker/types').ThemeMode,
effectiveConfig,
themeColors,
allThemes
);
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
}
} catch (error) {
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
}
}
/**
* Clean up all sessions
*/
cleanup(): void {
logger.info(`Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
// Use platform-specific kill to ensure proper termination on Windows
this.killPtyProcess(session.pty);
} catch {
// Ignore errors during cleanup
}
this.sessions.delete(id);
});
}
}
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(settingsService?: SettingsService): TerminalService {
if (!terminalService) {
terminalService = new TerminalService(settingsService);
}
return terminalService;
}