Merge remote-tracking branch 'origin/v0.15.0rc' into feature/bug-startup-warning-ignores-claude-oauth-credenti-fuzx

This commit is contained in:
Shirone
2026-02-15 17:37:17 +01:00
73 changed files with 6064 additions and 647 deletions

View File

@@ -485,7 +485,7 @@ const server = createServer(app);
// WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
const terminalService = getTerminalService(settingsService);
/**
* Authenticate WebSocket upgrade requests

View File

@@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
* decide its own reasoning depth.
*
* @param thinkingLevel - The thinking level to convert
* @returns Object with maxThinkingTokens if thinking is enabled
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
if (!thinkingLevel || thinkingLevel === 'none') {
return {};
}
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
// The model will use adaptive thinking by default
if (thinkingLevel === 'adaptive') {
logger.debug(
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
);
return {};
}
// Manual budget-based thinking for Haiku/Sonnet
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
logger.debug(
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`

View File

@@ -0,0 +1,25 @@
/**
* Terminal Theme Data - Re-export terminal themes from platform package
*
* This module re-exports terminal theme data for use in the server.
*/
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
import type { ThemeMode } from '@automaker/types';
import type { TerminalTheme } from '@automaker/platform';
/**
* Get terminal theme colors for a given theme mode
*/
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
return getThemeColors(theme);
}
/**
* Get all terminal themes
*/
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
return terminalThemeColors;
}
export default terminalThemeColors;

View File

@@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider {
// claudeCompatibleProvider takes precedence over claudeApiProfile
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
// Convert thinking level to token budget
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build thinking configuration
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
// Manual thinking (Haiku/Sonnet): use budget_tokens
const maxThinkingTokens =
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
const sdkOptions: Options = {
@@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider {
getAvailableModels(): ModelDefinition[] {
const models = [
{
id: 'claude-opus-4-5-20251101',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
id: 'claude-opus-4-6',
name: 'Claude Opus 4.6',
modelString: 'claude-opus-4-6',
provider: 'anthropic',
description: 'Most capable Claude model',
description: 'Most capable Claude model with adaptive thinking',
contextWindow: 200000,
maxOutputTokens: 16000,
maxOutputTokens: 128000,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,

View File

@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
export const CODEX_MODELS: ModelDefinition[] = [
// ========== Recommended Codex Models ==========
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
id: CODEX_MODEL_MAP.gpt53Codex,
name: 'GPT-5.3-Codex',
modelString: CODEX_MODEL_MAP.gpt53Codex,
provider: 'openai',
description:
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
description: 'Latest frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
modelString: CODEX_MODEL_MAP.gpt52Codex,
provider: 'openai',
description: 'Frontier agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt51CodexMax,
name: 'GPT-5.1-Codex-Max',
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
provider: 'openai',
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
description: 'Codex-optimized flagship for deep and fast reasoning.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
@@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.1-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
provider: 'openai',
description: 'Smaller, more cost-effective version for faster workflows.',
description: 'Optimized for codex. Cheaper, faster, but less capable.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
@@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
name: 'GPT-5.2',
modelString: CODEX_MODEL_MAP.gpt52,
provider: 'openai',
description: 'Best general agentic model for tasks across industries and domains.',
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,

View File

@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
import {
CliProvider,
type CliSpawnConfig,
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
windowsStrategy: 'direct',
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
'/usr/local/bin/cursor-agent',
],
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
// Windows paths are not used - we check for WSL installation instead
win32: [],
win32: [
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'cursor.exe'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor.cmd'
),
],
},
};
}
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
* 2. Cursor IDE with 'cursor agent' subcommand support
*/
protected detectCli(): CliDetectionResult {
if (process.platform === 'win32') {
const findInPath = (command: string): string | null => {
try {
const result = execSync(`where ${command}`, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
.trim()
.split(/\r?\n/)[0];
if (result && fs.existsSync(result)) {
return result;
}
} catch {
// Not in PATH
}
return null;
};
const isCursorAgentBinary = (cliPath: string) =>
cliPath.toLowerCase().includes('cursor-agent');
const supportsCursorAgentSubcommand = (cliPath: string) => {
try {
execSync(`"${cliPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
windowsHide: true,
});
return true;
} catch {
return false;
}
};
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
if (pathResult) {
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
return {
cliPath: pathResult,
useWsl: false,
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const config = this.getSpawnConfig();
for (const candidate of config.commonPaths.win32 || []) {
const resolved = candidate;
if (!fs.existsSync(resolved)) {
continue;
}
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
return {
cliPath: resolved,
useWsl: false,
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const wslLogger = (msg: string) => logger.debug(msg);
if (isWslAvailable({ logger: wslLogger })) {
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
if (wslResult) {
logger.debug(
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
);
return {
cliPath: 'wsl.exe',
useWsl: true,
wslCliPath: wslResult.wslPath,
wslDistribution: wslResult.distribution,
strategy: 'wsl',
};
}
}
logger.debug('cursor-agent not found on Windows');
return { cliPath: null, useWsl: false, strategy: 'direct' };
}
// First try standard detection (PATH, common paths, WSL)
const result = super.detectCli();
if (result.cliPath) {
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
// Cursor-specific: Check versions directory for any installed version
// This handles cases where cursor-agent is installed but not in PATH
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
try {
const versions = fs
.readdirSync(CursorProvider.VERSIONS_DIR)
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
// The Cursor IDE includes the agent as a subcommand: cursor agent
if (process.platform !== 'win32') {
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
}
}

View File

@@ -103,7 +103,7 @@ export class ProviderFactory {
/**
* Get the appropriate provider for a given model ID
*
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
* @param options Optional settings
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
* @returns Provider instance for the model

View File

@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { resolveModelString } from '@automaker/model-resolver';
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
import { getAppSpecPath } from '@automaker/platform';
import { simpleQuery } from '../../../providers/simple-query-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
import { FeatureLoader } from '../../../services/feature-loader.js';
import * as secureFs from '../../../lib/secure-fs.js';
import {
buildUserPrompt,
isValidEnhancementMode,
type EnhancementMode,
} from '../../../lib/enhancement-prompts.js';
import {
extractTechnologyStack,
extractXmlElements,
extractXmlSection,
unescapeXml,
} from '../../../lib/xml-extractor.js';
const logger = createLogger('EnhancePrompt');
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
error: string;
}
async function buildProjectContext(projectPath: string): Promise<string | null> {
const contextBlocks: string[] = [];
try {
const appSpecPath = getAppSpecPath(projectPath);
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
const projectName = extractXmlSection(specContent, 'project_name');
const overview = extractXmlSection(specContent, 'overview');
const techStack = extractTechnologyStack(specContent);
const coreSection = extractXmlSection(specContent, 'core_capabilities');
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
const summaryLines: string[] = [];
if (projectName) {
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
}
if (overview) {
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
}
if (techStack.length > 0) {
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
}
if (coreCapabilities.length > 0) {
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
}
if (summaryLines.length > 0) {
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
}
} catch (error) {
logger.debug('No app_spec.txt context available for enhancement', error);
}
try {
const featureLoader = new FeatureLoader();
const features = await featureLoader.getAll(projectPath);
const featureTitles = features
.map((feature) => feature.title || feature.name || feature.id)
.filter((title) => Boolean(title));
if (featureTitles.length > 0) {
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
contextBlocks.push(
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
featureTitles.length > 30 ? '\n- ...' : ''
}`
);
}
} catch (error) {
logger.debug('Failed to load existing features for enhancement context', error);
}
if (contextBlocks.length === 0) {
return null;
}
return contextBlocks.join('\n\n');
}
/**
* Create the enhance request handler
*
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
// Build the user prompt with few-shot examples
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
if (projectContext) {
logger.debug('Including project context in enhancement prompt');
}
// Check if the model is a provider model (like "GLM-4.5-Air")
// If so, get the provider config and resolved Claude model
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
// The system prompt is combined with user prompt since some providers
// don't have a separate system prompt concept
const result = await simpleQuery({
prompt: `${systemPrompt}\n\n${userPrompt}`,
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
model: resolvedModel,
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
maxTurns: 1,

View File

@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import { getImagesDir } from '@automaker/platform';
import { sanitizeFilename } from '@automaker/utils';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
@@ -39,7 +40,7 @@ export function createSaveImageHandler() {
// Generate unique filename with timestamp
const timestamp = Date.now();
const ext = path.extname(filename) || '.png';
const baseName = path.basename(filename, ext);
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
const filePath = path.join(imagesDir, uniqueFilename);

View File

@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js';
import { getTerminalService } from '../../../services/terminal-service.js';
/**
* Map server log level string to LogLevel enum
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
// Get old settings to detect theme changes
const oldSettings = await settingsService.getGlobalSettings();
const oldTheme = oldSettings?.theme;
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
settings.projects?.length ?? 0
);
// Handle theme change - regenerate terminal RC files for all projects
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
const terminalService = getTerminalService(settingsService);
const newTheme = updates.theme;
logger.info(
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
);
// Regenerate RC files for all projects with terminal config enabled
const projects = settings.projects || [];
for (const project of projects) {
try {
const projectSettings = await settingsService.getProjectSettings(project.path);
// Check if terminal config is enabled (global or project-specific)
const terminalConfigEnabled =
projectSettings.terminalConfig?.enabled !== false &&
settings.terminalConfig?.enabled === true;
if (terminalConfigEnabled) {
await terminalService.onThemeChange(project.path, newTheme);
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
}
} catch (error) {
logger.warn(
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
);
}
}
}
// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel];

View File

@@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
function isUntrackedLine(line: string): boolean {
return line.startsWith('?? ');
}
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
function isBlockingChangeLine(line: string): boolean {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
if (isUntrackedLine(line)) return false;
return true;
}
/**
* Check if there are uncommitted changes in the working directory
* Excludes .worktrees/ directory which is created by automaker
@@ -23,15 +38,7 @@ const execAsync = promisify(exec);
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd });
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
// Exclude .worktrees/ directory (created by automaker)
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
return true;
});
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
return lines.length > 0;
} catch {
return false;
@@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
async function getChangesSummary(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync('git status --short', { cwd });
const lines = stdout
.trim()
.split('\n')
.filter((line) => {
if (!line.trim()) return false;
// Exclude .worktrees/ directory
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
return true;
});
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
if (lines.length === 0) return '';
if (lines.length <= 5) return lines.join(', ');
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;

View File

@@ -2657,13 +2657,67 @@ Address the follow-up instructions above. Review the previous work and make the
// Load feature for commit message
const feature = await this.loadFeature(projectPath, featureId);
const commitMessage = feature
? `feat: ${this.extractTitleFromDescription(
feature.description
)}\n\nImplemented by Automaker auto-mode`
: `feat: Feature ${featureId}`;
? await this.generateCommitMessage(feature, workDir)
: `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
// Stage and commit
await execAsync('git add -A', { cwd: workDir });
// Determine which files to stage
// For feature branches, only stage files changed on this branch to avoid committing unrelated changes
let filesToStage: string[] = [];
try {
// Get the current branch
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: workDir,
});
const branch = currentBranch.trim();
// Get the base branch (usually main/master)
const { stdout: baseBranchOutput } = await execAsync(
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
{ cwd: workDir }
);
const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
// If we're on a feature branch (not the base branch), only stage files changed on this branch
if (branch !== baseBranch && feature?.branchName) {
try {
// Get files changed on this branch compared to base
const { stdout: branchFiles } = await execAsync(
`git diff --name-only ${baseBranch}...HEAD`,
{ cwd: workDir }
);
if (branchFiles.trim()) {
filesToStage = branchFiles.trim().split('\n').filter(Boolean);
logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
}
} catch (diffError) {
// If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
filesToStage = [];
}
}
} catch (error) {
logger.warn(`Could not determine branch-specific files: ${error}`);
}
// Stage files
if (filesToStage.length > 0) {
// Stage only the specific files changed on this branch
for (const file of filesToStage) {
try {
await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
} catch (error) {
logger.warn(`Failed to stage file ${file}: ${error}`);
}
}
} else {
// Fallback: stage all changes (original behavior)
// This happens for main branch features or when branch detection fails
await execAsync('git add -A', { cwd: workDir });
}
// Commit
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
cwd: workDir,
});
@@ -3664,13 +3718,14 @@ Format your response as a structured markdown document.`;
// Recovery cases:
// 1. Standard pending/ready/backlog statuses
// 2. Features with approved plans that have incomplete tasks (crash recovery)
// 3. Features stuck in 'in_progress' status (crash recovery)
// 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
const needsRecovery =
feature.status === 'pending' ||
feature.status === 'ready' ||
feature.status === 'backlog' ||
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
(feature.planSpec?.status === 'approved' &&
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
@@ -3710,7 +3765,7 @@ Format your response as a structured markdown document.`;
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
logger.info(
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}`
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
);
if (pendingFeatures.length === 0) {
@@ -3840,6 +3895,58 @@ Format your response as a structured markdown document.`;
return firstLine.substring(0, 57) + '...';
}
/**
* Generate a comprehensive commit message for a feature
* Includes title, description summary, and file statistics
*/
private async generateCommitMessage(feature: Feature, workDir: string): Promise<string> {
const title = this.extractTitleFromDescription(feature.description);
// Extract description summary (first 3-5 lines, up to 300 chars)
let descriptionSummary = '';
if (feature.description && feature.description.trim()) {
const lines = feature.description.split('\n').filter((l) => l.trim());
const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
descriptionSummary = summaryLines.join('\n');
// Limit to 300 characters
if (descriptionSummary.length > 300) {
descriptionSummary = descriptionSummary.substring(0, 297) + '...';
}
}
// Get file statistics to add context
let fileStats = '';
try {
const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
if (diffStat.trim()) {
// Extract just the summary line (last line with file count)
const statLines = diffStat.trim().split('\n');
const summaryLine = statLines[statLines.length - 1];
if (summaryLine && summaryLine.includes('file')) {
fileStats = `\n${summaryLine.trim()}`;
}
}
} catch {
// Ignore errors getting stats
}
// Build commit message
let message = `feat: ${title}`;
if (descriptionSummary && descriptionSummary !== title) {
message += `\n\n${descriptionSummary}`;
}
if (fileStats) {
message += fileStats;
}
message += '\n\nImplemented by Automaker auto-mode';
return message;
}
/**
* Get the planning prompt prefix based on feature's planning mode
*/
@@ -5430,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
continue;
}
// Check if feature was interrupted (in_progress or pipeline_*)
// Check if feature was interrupted (in_progress/interrupted or pipeline_*)
if (
feature.status === 'in_progress' ||
feature.status === 'interrupted' ||
(feature.status && feature.status.startsWith('pipeline_'))
) {
// Check if context (agent-output.md) exists

View File

@@ -37,6 +37,8 @@ export interface DevServerInfo {
flushTimeout: NodeJS.Timeout | null;
// Flag to indicate server is stopping (prevents output after stop)
stopping: boolean;
// Flag to indicate if URL has been detected from output
urlDetected: boolean;
}
// Port allocation starts at 3001 to avoid conflicts with common dev ports
@@ -103,6 +105,54 @@ class DevServerService {
}
}
/**
* Detect actual server URL from output
* Parses stdout/stderr for common URL patterns from dev servers
*/
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
// Skip if URL already detected
if (server.urlDetected) {
return;
}
// Common URL patterns from various dev servers:
// - Vite: "Local: http://localhost:5173/"
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
// - Generic: Any http:// or https:// URL
const urlPatterns = [
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
];
for (const pattern of urlPatterns) {
const match = content.match(pattern);
if (match && match[1]) {
const detectedUrl = match[1].trim();
// Validate it looks like a reasonable URL
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
server.url = detectedUrl;
server.urlDetected = true;
logger.info(
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
);
// Emit URL update event
if (this.emitter) {
this.emitter.emit('dev-server:url-detected', {
worktreePath: server.worktreePath,
url: detectedUrl,
timestamp: new Date().toISOString(),
});
}
break;
}
}
}
}
/**
* Handle incoming stdout/stderr data from dev server process
* Buffers data for scrollback replay and schedules throttled emission
@@ -115,6 +165,9 @@ class DevServerService {
const content = data.toString();
// Try to detect actual server URL from output
this.detectUrlFromOutput(server, content);
// Append to scrollback buffer for replay on reconnect
this.appendToScrollback(server, content);
@@ -446,13 +499,14 @@ class DevServerService {
const serverInfo: DevServerInfo = {
worktreePath,
port,
url: `http://${hostname}:${port}`,
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
process: devProcess,
startedAt: new Date(),
scrollbackBuffer: '',
outputBuffer: '',
flushTimeout: null,
stopping: false,
urlDetected: false, // Will be set to true when actual URL is detected from output
};
// Capture stdout with buffer management and event emission

View File

@@ -13,6 +13,14 @@ import * as path from 'path';
// 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
@@ -24,6 +32,27 @@ import {
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
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
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;
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
!!(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.
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
const platform = os.platform();
const shellPaths = getShellPaths();
// Helper to get basename handling both path separators
const getBasename = (shellPath: string): string => {
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
};
// Helper to get shell args based on shell name
const getShellArgs = (shell: string): string[] => {
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
// PowerShell and cmd don't need --login
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
return [];
}
// sh doesn't support --login in all implementations
if (shellName === 'sh') {
return [];
}
// bash, zsh, and other POSIX shells support --login
return ['--login'];
};
// 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 || getBasename(allowedShell) === getBasename(userShell)) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed, continue
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
if (userShell && platform !== 'win32') {
// Try to find userShell in allowed paths
for (const allowedShell of shellPaths) {
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try {
if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) };
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
}
} catch {
// Path not allowed, continue searching
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) {
try {
if (systemPathExists(shell)) {
return { shell, args: getShellArgs(shell) };
return { shell, args: getShellArgsForPath(shell) };
}
} catch {
// Path not allowed or doesn't exist, continue to next
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
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
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
}
}
// 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',
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
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}`);
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
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
*/
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
// Singleton instance
let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService {
export function getTerminalService(settingsService?: SettingsService): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
terminalService = new TerminalService(settingsService);
}
return terminalService;
}