Merge branch 'v0.11.0rc' into fix/pipeline-resume-edge-cases

This commit is contained in:
webdevcody
2026-01-12 23:49:33 -05:00
562 changed files with 65881 additions and 13321 deletions

View File

@@ -6,13 +6,16 @@
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions } from '@automaker/types';
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
loadContextFiles,
createLogger,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -20,11 +23,12 @@ import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getMCPPermissionSettings,
getPromptCustomization,
getSkillsConfiguration,
getSubagentsConfiguration,
getCustomSubagents,
} from '../lib/settings-helpers.js';
interface Message {
@@ -45,6 +49,7 @@ interface QueuedPrompt {
message: string;
imagePaths?: string[];
model?: string;
thinkingLevel?: ThinkingLevel;
addedAt: string;
}
@@ -54,6 +59,8 @@ interface Session {
abortController: AbortController | null;
workingDirectory: string;
model?: string;
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
}
@@ -142,12 +149,16 @@ export class AgentService {
workingDirectory,
imagePaths,
model,
thinkingLevel,
reasoningEffort,
}: {
sessionId: string;
message: string;
workingDirectory?: string;
imagePaths?: string[];
model?: string;
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
}) {
const session = this.sessions.get(sessionId);
if (!session) {
@@ -160,11 +171,29 @@ export class AgentService {
throw new Error('Agent is already processing a message');
}
// Update session model if provided
// Update session model, thinking level, and reasoning effort if provided
if (model) {
session.model = model;
await this.updateSession(sessionId, { model });
}
if (thinkingLevel !== undefined) {
session.thinkingLevel = thinkingLevel;
}
if (reasoningEffort !== undefined) {
session.reasoningEffort = reasoningEffort;
}
// Validate vision support before processing images
const effectiveModel = model || session.model;
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
if (!supportsVision) {
throw new Error(
`This model (${effectiveModel}) does not support image input. ` +
`Please switch to a model that supports vision, or remove the images and try again.`
);
}
}
// Read images and convert to base64
const images: Message['images'] = [];
@@ -226,22 +255,34 @@ export class AgentService {
'[AgentService]'
);
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(
this.settingsService,
'[AgentService]'
);
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
// Load MCP permission settings (global setting only)
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
// Get Skills configuration from settings
const skillsConfig = this.settingsService
? await getSkillsConfiguration(this.settingsService)
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
// Get Subagents configuration from settings
const subagentsConfig = this.settingsService
? await getSubagentsConfiguration(this.settingsService)
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
// Get custom subagents from settings (merge global + project-level) only if enabled
const customSubagents =
this.settingsService && subagentsConfig.enabled
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
: undefined;
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
// Use the user's message as task context for smart memory selection
const contextResult = await loadContextFiles({
projectPath: effectiveWorkDir,
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
taskContext: {
title: message.substring(0, 200), // Use first 200 chars as title
description: message,
},
});
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
@@ -255,6 +296,9 @@ export class AgentService {
: baseSystemPrompt;
// Build SDK options using centralized configuration
// Use thinking level and reasoning effort from request, or fall back to session's stored values
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
const sdkOptions = createChatOptions({
cwd: effectiveWorkDir,
model: model,
@@ -262,36 +306,78 @@ export class AgentService {
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
});
// Extract model, maxTurns, and allowedTools from SDK options
const effectiveModel = sdkOptions.model!;
const maxTurns = sdkOptions.maxTurns;
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
// Get provider for this model
// Build merged settingSources array using Set for automatic deduplication
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
);
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
// Enhance allowedTools with Skills and Subagents tools
// These tools are not in the provider's default set - they're added dynamically based on settings
const needsSkillTool = skillsConfig.shouldIncludeInTools;
const needsTaskTool =
subagentsConfig.shouldIncludeInTools &&
customSubagents &&
Object.keys(customSubagents).length > 0;
// Base tools that match the provider's default set
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
if (allowedTools) {
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
// Add Skill tool if skills are enabled
if (needsSkillTool && !allowedTools.includes('Skill')) {
allowedTools.push('Skill');
}
// Add Task tool if custom subagents are configured
if (needsTaskTool && !allowedTools.includes('Task')) {
allowedTools.push('Task');
}
} else if (needsSkillTool || needsTaskTool) {
// If no allowedTools specified but we need to add Skill/Task tools,
// build the full list including base tools
allowedTools = [...baseTools];
if (needsSkillTool) {
allowedTools.push('Skill');
}
if (needsTaskTool) {
allowedTools.push('Task');
}
}
// Get provider for this model (with prefix)
const provider = ProviderFactory.getProviderForModel(effectiveModel);
// Strip provider prefix - providers should receive bare model IDs
const bareModel = stripProviderPrefix(effectiveModel);
// Build options for provider
const options: ExecuteOptions = {
prompt: '', // Will be set below based on images
model: effectiveModel,
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max")
cwd: effectiveWorkDir,
systemPrompt: sdkOptions.systemPrompt,
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
settingSources: settingSources.length > 0 ? settingSources : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
agents: customSubagents, // Pass custom subagents for task delegation
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
};
// Build prompt content with images
@@ -372,6 +458,53 @@ export class AgentService {
content: responseText,
toolUses,
});
} else if (msg.type === 'error') {
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
// streamed error messages instead of throwing. Handle these here so the
// Agent Runner UX matches the Claude/Cursor behavior without changing
// their provider implementations.
const rawErrorText =
(typeof msg.error === 'string' && msg.error.trim()) ||
'Unexpected error from provider during agent execution.';
const errorInfo = classifyError(new Error(rawErrorText));
// Keep the provider-supplied text intact (Codex already includes helpful tips),
// only add a small rate-limit hint when we can detect it.
const enhancedText = errorInfo.isRateLimit
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
: rawErrorText;
this.logger.error('Provider error during agent execution:', {
type: errorInfo.type,
message: errorInfo.message,
});
// Mark session as no longer running so the UI and queue stay in sync
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: 'assistant',
content: `Error: ${enhancedText}`,
timestamp: new Date().toISOString(),
isError: true,
};
session.messages.push(errorMessage);
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: 'error',
error: enhancedText,
message: errorMessage,
});
// Don't continue streaming after an error message
return {
success: false,
};
}
}
@@ -628,7 +761,12 @@ export class AgentService {
*/
async addToQueue(
sessionId: string,
prompt: { message: string; imagePaths?: string[]; model?: string }
prompt: {
message: string;
imagePaths?: string[];
model?: string;
thinkingLevel?: ThinkingLevel;
}
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
const session = this.sessions.get(sessionId);
if (!session) {
@@ -640,6 +778,7 @@ export class AgentService {
message: prompt.message,
imagePaths: prompt.imagePaths,
model: prompt.model,
thinkingLevel: prompt.thinkingLevel,
addedAt: new Date().toISOString(),
};
@@ -769,6 +908,7 @@ export class AgentService {
message: nextPrompt.message,
imagePaths: nextPrompt.imagePaths,
model: nextPrompt.model,
thinkingLevel: nextPrompt.thinkingLevel,
});
} catch (error) {
this.logger.error('Failed to process queued prompt:', error);

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
import * as os from 'os';
import * as pty from 'node-pty';
import { ClaudeUsage } from '../routes/claude/types.js';
import { createLogger } from '@automaker/utils';
/**
* Claude Usage Service
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
* - macOS: Uses 'expect' command for PTY
* - Windows/Linux: Uses node-pty for PTY
*/
const logger = createLogger('ClaudeUsage');
export class ClaudeUsageService {
private claudeBinary = 'claude';
private timeout = 30000; // 30 second timeout
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
const ptyProcess = pty.spawn(shell, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
let ptyProcess: any = null;
try {
ptyProcess = pty.spawn(shell, args, {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: workingDirectory,
env: {
...process.env,
TERM: 'xterm-256color',
} as Record<string, string>,
});
} catch (spawnError) {
// pty.spawn() can throw synchronously if the native module fails to load
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
// Return a user-friendly error instead of crashing
reject(
new Error(
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
)
);
return;
}
const timeoutId = setTimeout(() => {
if (!settled) {
settled = true;
ptyProcess.kill();
if (ptyProcess && !ptyProcess.killed) {
ptyProcess.kill();
}
// Don't fail if we have data - return it instead
if (output.includes('Current session')) {
resolve(output);
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
}
}, this.timeout);
ptyProcess.onData((data) => {
ptyProcess.onData((data: string) => {
output += data;
// Check if we've seen the usage data (look for "Current session")
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
hasSeenUsageData = true;
// Wait for full output, then send escape to exit
setTimeout(() => {
if (!settled) {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
setTimeout(() => {
if (!settled) {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.kill('SIGTERM');
}
}, 2000);
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
setTimeout(() => {
if (!settled) {
if (!settled && ptyProcess && !ptyProcess.killed) {
ptyProcess.write('\x1b'); // Send escape key
}
}, 3000);
}
});
ptyProcess.onExit(({ exitCode }) => {
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
clearTimeout(timeoutId);
if (settled) return;
settled = true;

View File

@@ -0,0 +1,212 @@
import { spawn, type ChildProcess } from 'child_process';
import readline from 'readline';
import { findCodexCliPath } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type {
AppServerModelResponse,
AppServerAccountResponse,
AppServerRateLimitsResponse,
JsonRpcRequest,
} from '@automaker/types';
const logger = createLogger('CodexAppServer');
/**
* CodexAppServerService
*
* Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol.
* Handles process spawning, JSON-RPC messaging, and cleanup.
*
* Connection strategy: Spawn on-demand (new process for each method call)
*/
export class CodexAppServerService {
private cachedCliPath: string | null = null;
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
* Fetch available models from app-server
*/
async getModels(): Promise<AppServerModelResponse | null> {
const result = await this.executeJsonRpc<AppServerModelResponse>((sendRequest) => {
return sendRequest('model/list', {});
});
if (result) {
logger.info(`[getModels] ✓ Fetched ${result.data.length} models`);
}
return result;
}
/**
* Fetch account information from app-server
*/
async getAccount(): Promise<AppServerAccountResponse | null> {
return this.executeJsonRpc<AppServerAccountResponse>((sendRequest) => {
return sendRequest('account/read', { refreshToken: false });
});
}
/**
* Fetch rate limits from app-server
*/
async getRateLimits(): Promise<AppServerRateLimitsResponse | null> {
return this.executeJsonRpc<AppServerRateLimitsResponse>((sendRequest) => {
return sendRequest('account/rateLimits/read', {});
});
}
/**
* Execute JSON-RPC requests via Codex app-server
*
* This method:
* 1. Spawns a new `codex app-server` process
* 2. Handles JSON-RPC initialization handshake
* 3. Executes user-provided requests
* 4. Cleans up the process
*
* @param requestFn - Function that receives sendRequest helper and returns a promise
* @returns Result of the JSON-RPC request or null on failure
*/
private async executeJsonRpc<T>(
requestFn: (sendRequest: <R>(method: string, params?: unknown) => Promise<R>) => Promise<T>
): Promise<T | null> {
let childProcess: ChildProcess | null = null;
try {
const cliPath = this.cachedCliPath || (await findCodexCliPath());
if (!cliPath) {
return null;
}
// On Windows, .cmd files must be run through shell
const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd');
childProcess = spawn(cliPath, ['app-server'], {
cwd: process.cwd(),
env: {
...process.env,
TERM: 'dumb',
},
stdio: ['pipe', 'pipe', 'pipe'],
shell: needsShell,
});
if (!childProcess.stdin || !childProcess.stdout) {
throw new Error('Failed to create stdio pipes');
}
// Setup readline for reading JSONL responses
const rl = readline.createInterface({
input: childProcess.stdout,
crlfDelay: Infinity,
});
// Message ID counter for JSON-RPC
let messageId = 0;
const pendingRequests = new Map<
number,
{
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: NodeJS.Timeout;
}
>();
// Process incoming messages
rl.on('line', (line) => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
// Handle response to our request
if ('id' in message && message.id !== undefined) {
const pending = pendingRequests.get(message.id);
if (pending) {
clearTimeout(pending.timeout);
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || 'Unknown error'));
} else {
pending.resolve(message.result);
}
}
}
// Ignore notifications (no id field)
} catch {
// Ignore parse errors for non-JSON lines
}
});
// Helper to send JSON-RPC request and wait for response
const sendRequest = <R>(method: string, params?: unknown): Promise<R> => {
return new Promise((resolve, reject) => {
const id = ++messageId;
const request: JsonRpcRequest = {
method,
id,
params: params ?? {},
};
// Set timeout for request (10 seconds)
const timeout = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}, 10000);
pendingRequests.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
timeout,
});
childProcess!.stdin!.write(JSON.stringify(request) + '\n');
});
};
// Helper to send notification (no response expected)
const sendNotification = (method: string, params?: unknown): void => {
const notification = params ? { method, params } : { method };
childProcess!.stdin!.write(JSON.stringify(notification) + '\n');
};
// 1. Initialize the app-server
await sendRequest('initialize', {
clientInfo: {
name: 'automaker',
title: 'AutoMaker',
version: '1.0.0',
},
});
// 2. Send initialized notification
sendNotification('initialized');
// 3. Execute user-provided requests
const result = await requestFn(sendRequest);
// Clean up
rl.close();
childProcess.kill('SIGTERM');
return result;
} catch (error) {
logger.error('[executeJsonRpc] Failed:', error);
return null;
} finally {
// Ensure process is killed
if (childProcess && !childProcess.killed) {
childProcess.kill('SIGTERM');
}
}
}
}

View File

@@ -0,0 +1,258 @@
import path from 'path';
import { secureFs } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type { AppServerModel } from '@automaker/types';
import type { CodexAppServerService } from './codex-app-server-service.js';
const logger = createLogger('CodexModelCache');
/**
* Codex model with UI-compatible format
*/
export interface CodexModel {
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}
/**
* Cache structure stored on disk
*/
interface CodexModelCache {
models: CodexModel[];
cachedAt: number;
ttl: number;
}
/**
* CodexModelCacheService
*
* Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence.
*
* Features:
* - 1-hour TTL (configurable)
* - Atomic file writes (temp file + rename)
* - Thread-safe (deduplicates concurrent refresh requests)
* - Auto-bootstrap on service creation
* - Graceful fallback (returns empty array on errors)
*/
export class CodexModelCacheService {
private cacheFilePath: string;
private ttl: number;
private appServerService: CodexAppServerService;
private inFlightRefresh: Promise<CodexModel[]> | null = null;
constructor(
dataDir: string,
appServerService: CodexAppServerService,
ttl: number = 3600000 // 1 hour default
) {
this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json');
this.ttl = ttl;
this.appServerService = appServerService;
}
/**
* Get models from cache or fetch if stale
*
* @param forceRefresh - If true, bypass cache and fetch fresh data
* @returns Array of Codex models (empty array if unavailable)
*/
async getModels(forceRefresh = false): Promise<CodexModel[]> {
// If force refresh, skip cache
if (forceRefresh) {
return this.refreshModels();
}
// Try to load from cache
const cached = await this.loadFromCache();
if (cached) {
const age = Date.now() - cached.cachedAt;
const isStale = age > cached.ttl;
if (!isStale) {
logger.info(
`[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)`
);
return cached.models;
}
}
// Cache is stale or missing, refresh
return this.refreshModels();
}
/**
* Get models with cache metadata
*
* @param forceRefresh - If true, bypass cache and fetch fresh data
* @returns Object containing models and cache timestamp
*/
async getModelsWithMetadata(
forceRefresh = false
): Promise<{ models: CodexModel[]; cachedAt: number }> {
const models = await this.getModels(forceRefresh);
// Try to get the actual cache timestamp
const cached = await this.loadFromCache();
const cachedAt = cached?.cachedAt ?? Date.now();
return { models, cachedAt };
}
/**
* Refresh models from app-server and update cache
*
* Thread-safe: Deduplicates concurrent refresh requests
*/
async refreshModels(): Promise<CodexModel[]> {
// Deduplicate concurrent refresh requests
if (this.inFlightRefresh) {
return this.inFlightRefresh;
}
// Start new refresh
this.inFlightRefresh = this.doRefresh();
try {
const models = await this.inFlightRefresh;
return models;
} finally {
this.inFlightRefresh = null;
}
}
/**
* Clear the cache file
*/
async clearCache(): Promise<void> {
logger.info('[clearCache] Clearing cache...');
try {
await secureFs.unlink(this.cacheFilePath);
logger.info('[clearCache] Cache cleared');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('[clearCache] Failed to clear cache:', error);
}
}
}
/**
* Internal method to perform the actual refresh
*/
private async doRefresh(): Promise<CodexModel[]> {
try {
// Check if app-server is available
const isAvailable = await this.appServerService.isAvailable();
if (!isAvailable) {
return [];
}
// Fetch models from app-server
const response = await this.appServerService.getModels();
if (!response || !response.data) {
return [];
}
// Transform models to UI format
const models = response.data.map((model) => this.transformModel(model));
// Save to cache
await this.saveToCache(models);
logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`);
return models;
} catch (error) {
logger.error('[doRefresh] Refresh failed:', error);
return [];
}
}
/**
* Transform app-server model to UI-compatible format
*/
private transformModel(appServerModel: AppServerModel): CodexModel {
return {
id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility
label: appServerModel.displayName,
description: appServerModel.description,
hasThinking: appServerModel.supportedReasoningEfforts.length > 0,
supportsVision: true, // All Codex models support vision
tier: this.inferTier(appServerModel.id),
isDefault: appServerModel.isDefault,
};
}
/**
* Infer tier from model ID
*/
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
return 'premium';
}
if (modelId.includes('mini')) {
return 'basic';
}
return 'standard';
}
/**
* Load cache from disk
*/
private async loadFromCache(): Promise<CodexModelCache | null> {
try {
const content = await secureFs.readFile(this.cacheFilePath, 'utf-8');
const cache = JSON.parse(content.toString()) as CodexModelCache;
// Validate cache structure
if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') {
logger.warn('[loadFromCache] Invalid cache structure, ignoring');
return null;
}
return cache;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('[loadFromCache] Failed to read cache:', error);
}
return null;
}
}
/**
* Save cache to disk (atomic write)
*/
private async saveToCache(models: CodexModel[]): Promise<void> {
const cache: CodexModelCache = {
models,
cachedAt: Date.now(),
ttl: this.ttl,
};
const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`;
try {
// Write to temp file
const content = JSON.stringify(cache, null, 2);
await secureFs.writeFile(tempPath, content, 'utf-8');
// Atomic rename
await secureFs.rename(tempPath, this.cacheFilePath);
} catch (error) {
logger.error('[saveToCache] Failed to save cache:', error);
// Clean up temp file
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
}
}
}

View File

@@ -0,0 +1,348 @@
import {
findCodexCliPath,
getCodexAuthPath,
systemPathExists,
systemPathReadFile,
} from '@automaker/platform';
import { createLogger } from '@automaker/utils';
import type { CodexAppServerService } from './codex-app-server-service.js';
const logger = createLogger('CodexUsage');
export interface CodexRateLimitWindow {
limit: number;
used: number;
remaining: number;
usedPercent: number;
windowDurationMins: number;
resetsAt: number;
}
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
export interface CodexUsageData {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
}
/**
* Codex Usage Service
*
* Fetches usage data from Codex CLI using the app-server JSON-RPC API.
* Falls back to auth file parsing if app-server is unavailable.
*/
export class CodexUsageService {
private cachedCliPath: string | null = null;
private appServerService: CodexAppServerService | null = null;
private accountPlanTypeArray: CodexPlanType[] = [
'free',
'plus',
'pro',
'team',
'enterprise',
'edu',
];
constructor(appServerService?: CodexAppServerService) {
this.appServerService = appServerService || null;
}
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
* Attempt to fetch usage data
*
* Priority order:
* 1. Codex app-server JSON-RPC API (most reliable, provides real-time data)
* 2. Auth file JWT parsing (fallback for plan type)
*/
async fetchUsageData(): Promise<CodexUsageData> {
logger.info('[fetchUsageData] Starting...');
const cliPath = this.cachedCliPath || (await findCodexCliPath());
if (!cliPath) {
logger.error('[fetchUsageData] Codex CLI not found');
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
}
logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`);
// Try to get usage from Codex app-server (most reliable method)
const appServerUsage = await this.fetchFromAppServer();
if (appServerUsage) {
logger.info('[fetchUsageData] ✓ Fetched usage from app-server');
return appServerUsage;
}
logger.info('[fetchUsageData] App-server failed, trying auth file fallback...');
// Fallback: try to parse usage from auth file
const authUsage = await this.fetchFromAuthFile();
if (authUsage) {
logger.info('[fetchUsageData] ✓ Fetched usage from auth file');
return authUsage;
}
logger.info('[fetchUsageData] All methods failed, returning unknown');
// If all else fails, return unknown
return {
rateLimits: {
planType: 'unknown',
},
lastUpdated: new Date().toISOString(),
};
}
/**
* Fetch usage data from Codex app-server using JSON-RPC API
* This is the most reliable method as it gets real-time data from OpenAI
*/
private async fetchFromAppServer(): Promise<CodexUsageData | null> {
try {
// Use CodexAppServerService if available
if (!this.appServerService) {
return null;
}
// Fetch account and rate limits in parallel
const [accountResult, rateLimitsResult] = await Promise.all([
this.appServerService.getAccount(),
this.appServerService.getRateLimits(),
]);
if (!accountResult) {
return null;
}
// Build response
// Prefer planType from rateLimits (more accurate/current) over account (can be stale)
let planType: CodexPlanType = 'unknown';
// First try rate limits planType (most accurate)
const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType;
if (rateLimitsPlanType) {
const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType;
if (this.accountPlanTypeArray.includes(normalizedType)) {
planType = normalizedType;
}
}
// Fall back to account planType if rate limits didn't have it
if (planType === 'unknown' && accountResult.account?.planType) {
const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType;
if (this.accountPlanTypeArray.includes(normalizedType)) {
planType = normalizedType;
}
}
const result: CodexUsageData = {
rateLimits: {
planType,
},
lastUpdated: new Date().toISOString(),
};
// Add rate limit info if available
if (rateLimitsResult?.rateLimits?.primary) {
const primary = rateLimitsResult.rateLimits.primary;
result.rateLimits!.primary = {
limit: -1, // Not provided by API
used: -1, // Not provided by API
remaining: -1, // Not provided by API
usedPercent: primary.usedPercent,
windowDurationMins: primary.windowDurationMins,
resetsAt: primary.resetsAt,
};
}
// Add secondary rate limit if available
if (rateLimitsResult?.rateLimits?.secondary) {
const secondary = rateLimitsResult.rateLimits.secondary;
result.rateLimits!.secondary = {
limit: -1, // Not provided by API
used: -1, // Not provided by API
remaining: -1, // Not provided by API
usedPercent: secondary.usedPercent,
windowDurationMins: secondary.windowDurationMins,
resetsAt: secondary.resetsAt,
};
}
logger.info(
`[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%`
);
return result;
} catch (error) {
logger.error('[fetchFromAppServer] Failed:', error);
return null;
}
}
/**
* Extract plan type from auth file JWT token
* Returns the actual plan type or 'unknown' if not available
*/
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
try {
const authFilePath = getCodexAuthPath();
logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`);
const exists = systemPathExists(authFilePath);
if (!exists) {
logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist');
return 'unknown';
}
const authContent = await systemPathReadFile(authFilePath);
const authData = JSON.parse(authContent);
if (!authData.tokens?.id_token) {
logger.info('[getPlanTypeFromAuthFile] No id_token in auth file');
return 'unknown';
}
const claims = this.parseJwt(authData.tokens.id_token);
if (!claims) {
logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT');
return 'unknown';
}
logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims));
// Extract plan type from nested OpenAI auth object with type validation
const openaiAuthClaim = claims['https://api.openai.com/auth'];
logger.info(
'[getPlanTypeFromAuthFile] OpenAI auth claim:',
JSON.stringify(openaiAuthClaim, null, 2)
);
let accountType: string | undefined;
let isSubscriptionExpired = false;
if (
openaiAuthClaim &&
typeof openaiAuthClaim === 'object' &&
!Array.isArray(openaiAuthClaim)
) {
const openaiAuth = openaiAuthClaim as Record<string, unknown>;
if (typeof openaiAuth.chatgpt_plan_type === 'string') {
accountType = openaiAuth.chatgpt_plan_type;
}
// Check if subscription has expired
if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') {
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
if (!isNaN(expiryDate.getTime())) {
isSubscriptionExpired = expiryDate < new Date();
}
}
} else {
// Fallback: try top-level claim names
const possibleClaimNames = [
'https://chatgpt.com/account_type',
'account_type',
'plan',
'plan_type',
];
for (const claimName of possibleClaimNames) {
const claimValue = claims[claimName];
if (claimValue && typeof claimValue === 'string') {
accountType = claimValue;
break;
}
}
}
// If subscription is expired, treat as free plan
if (isSubscriptionExpired && accountType && accountType !== 'free') {
logger.info(`Subscription expired, using "free" instead of "${accountType}"`);
accountType = 'free';
}
if (accountType) {
const normalizedType = accountType.toLowerCase() as CodexPlanType;
logger.info(
`[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"`
);
if (this.accountPlanTypeArray.includes(normalizedType)) {
logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`);
return normalizedType;
}
} else {
logger.info('[getPlanTypeFromAuthFile] No account type found in claims');
}
} catch (error) {
logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error);
}
logger.info('[getPlanTypeFromAuthFile] Returning unknown');
return 'unknown';
}
/**
* Try to extract usage info from the Codex auth file
* Reuses getPlanTypeFromAuthFile to avoid code duplication
*/
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
logger.info('[fetchFromAuthFile] Starting...');
try {
const planType = await this.getPlanTypeFromAuthFile();
logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`);
if (planType === 'unknown') {
logger.info('[fetchFromAuthFile] Plan type unknown, returning null');
return null;
}
const result: CodexUsageData = {
rateLimits: {
planType,
},
lastUpdated: new Date().toISOString(),
};
logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2));
return result;
} catch (error) {
logger.error('[fetchFromAuthFile] Failed to parse auth file:', error);
}
return null;
}
/**
* Parse JWT token to extract claims
*/
private parseJwt(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use Buffer for Node.js environment
const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
return JSON.parse(jsonPayload);
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,280 @@
/**
* Cursor Config Service
*
* Manages Cursor CLI permissions configuration files:
* - Global: ~/.cursor/cli-config.json
* - Project: <project>/.cursor/cli.json
*
* Based on: https://cursor.com/docs/cli/reference/configuration
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type {
CursorCliConfigFile,
CursorCliPermissions,
CursorPermissionProfile,
} from '@automaker/types';
import {
CURSOR_STRICT_PROFILE,
CURSOR_DEVELOPMENT_PROFILE,
CURSOR_PERMISSION_PROFILES,
} from '@automaker/types';
const logger = createLogger('CursorConfigService');
/**
* Get the path to the global Cursor CLI config
*/
export function getGlobalConfigPath(): string {
// Windows: $env:USERPROFILE\.cursor\cli-config.json
// macOS/Linux: ~/.cursor/cli-config.json
// XDG_CONFIG_HOME override on Linux: $XDG_CONFIG_HOME/cursor/cli-config.json
const xdgConfig = process.env.XDG_CONFIG_HOME;
const cursorConfigDir = process.env.CURSOR_CONFIG_DIR;
if (cursorConfigDir) {
return path.join(cursorConfigDir, 'cli-config.json');
}
if (process.platform === 'linux' && xdgConfig) {
return path.join(xdgConfig, 'cursor', 'cli-config.json');
}
return path.join(os.homedir(), '.cursor', 'cli-config.json');
}
/**
* Get the path to a project's Cursor CLI config
*/
export function getProjectConfigPath(projectPath: string): string {
return path.join(projectPath, '.cursor', 'cli.json');
}
/**
* Read the global Cursor CLI config
*/
export async function readGlobalConfig(): Promise<CursorCliConfigFile | null> {
const configPath = getGlobalConfigPath();
try {
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content) as CursorCliConfigFile;
logger.debug('Read global Cursor config from:', configPath);
return config;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.debug('Global Cursor config not found at:', configPath);
return null;
}
logger.error('Failed to read global Cursor config:', error);
throw error;
}
}
/**
* Write the global Cursor CLI config
*/
export async function writeGlobalConfig(config: CursorCliConfigFile): Promise<void> {
const configPath = getGlobalConfigPath();
const configDir = path.dirname(configPath);
// Ensure directory exists
await fs.mkdir(configDir, { recursive: true });
// Write config
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
logger.info('Wrote global Cursor config to:', configPath);
}
/**
* Read a project's Cursor CLI config
*/
export async function readProjectConfig(projectPath: string): Promise<CursorCliConfigFile | null> {
const configPath = getProjectConfigPath(projectPath);
try {
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content) as CursorCliConfigFile;
logger.debug('Read project Cursor config from:', configPath);
return config;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.debug('Project Cursor config not found at:', configPath);
return null;
}
logger.error('Failed to read project Cursor config:', error);
throw error;
}
}
/**
* Write a project's Cursor CLI config
*
* Note: Project-level config ONLY supports permissions.
* The version field and other settings are global-only.
* See: https://cursor.com/docs/cli/reference/configuration
*/
export async function writeProjectConfig(
projectPath: string,
config: CursorCliConfigFile
): Promise<void> {
const configPath = getProjectConfigPath(projectPath);
const configDir = path.dirname(configPath);
// Ensure .cursor directory exists
await fs.mkdir(configDir, { recursive: true });
// Write config (project config ONLY supports permissions - no version field!)
const projectConfig = {
permissions: config.permissions,
};
await fs.writeFile(configPath, JSON.stringify(projectConfig, null, 2));
logger.info('Wrote project Cursor config to:', configPath);
}
/**
* Delete a project's Cursor CLI config
*/
export async function deleteProjectConfig(projectPath: string): Promise<void> {
const configPath = getProjectConfigPath(projectPath);
try {
await fs.unlink(configPath);
logger.info('Deleted project Cursor config:', configPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
}
/**
* Get the effective permissions for a project
* Project config takes precedence over global config
*/
export async function getEffectivePermissions(
projectPath?: string
): Promise<CursorCliPermissions | null> {
// Try project config first
if (projectPath) {
const projectConfig = await readProjectConfig(projectPath);
if (projectConfig?.permissions) {
return projectConfig.permissions;
}
}
// Fall back to global config
const globalConfig = await readGlobalConfig();
return globalConfig?.permissions || null;
}
/**
* Apply a predefined permission profile to a project
*/
export async function applyProfileToProject(
projectPath: string,
profileId: CursorPermissionProfile
): Promise<void> {
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
if (!profile) {
throw new Error(`Unknown permission profile: ${profileId}`);
}
await writeProjectConfig(projectPath, {
version: 1,
permissions: profile.permissions,
});
logger.info(`Applied "${profile.name}" profile to project:`, projectPath);
}
/**
* Apply a predefined permission profile globally
*/
export async function applyProfileGlobally(profileId: CursorPermissionProfile): Promise<void> {
const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId);
if (!profile) {
throw new Error(`Unknown permission profile: ${profileId}`);
}
// Read existing global config to preserve other settings
const existingConfig = await readGlobalConfig();
await writeGlobalConfig({
version: 1,
...existingConfig,
permissions: profile.permissions,
});
logger.info(`Applied "${profile.name}" profile globally`);
}
/**
* Detect which profile matches the current permissions
*/
export function detectProfile(
permissions: CursorCliPermissions | null
): CursorPermissionProfile | null {
if (!permissions) {
return null;
}
// Check if permissions match a predefined profile
for (const profile of CURSOR_PERMISSION_PROFILES) {
const allowMatch =
JSON.stringify(profile.permissions.allow.sort()) === JSON.stringify(permissions.allow.sort());
const denyMatch =
JSON.stringify(profile.permissions.deny.sort()) === JSON.stringify(permissions.deny.sort());
if (allowMatch && denyMatch) {
return profile.id;
}
}
return 'custom';
}
/**
* Generate example config file content
*/
export function generateExampleConfig(profileId: CursorPermissionProfile = 'development'): string {
const profile =
CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId) || CURSOR_DEVELOPMENT_PROFILE;
const config: CursorCliConfigFile = {
version: 1,
permissions: profile.permissions,
};
return JSON.stringify(config, null, 2);
}
/**
* Check if a project has Cursor CLI config
*/
export async function hasProjectConfig(projectPath: string): Promise<boolean> {
const configPath = getProjectConfigPath(projectPath);
try {
await fs.access(configPath);
return true;
} catch {
return false;
}
}
/**
* Get all available permission profiles
*/
export function getAvailableProfiles() {
return CURSOR_PERMISSION_PROFILES;
}
// Export profile constants for convenience
export { CURSOR_STRICT_PROFILE, CURSOR_DEVELOPMENT_PROFILE };

View File

@@ -11,6 +11,9 @@ import { spawn, execSync, type ChildProcess } from 'child_process';
import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
import { createLogger } from '@automaker/utils';
const logger = createLogger('DevServerService');
export interface DevServerInfo {
worktreePath: string;
@@ -69,7 +72,7 @@ class DevServerService {
for (const pid of pids) {
try {
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
logger.debug(`Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
@@ -82,7 +85,7 @@ class DevServerService {
for (const pid of pids) {
try {
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
logger.debug(`Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
}
@@ -93,7 +96,7 @@ class DevServerService {
}
} catch (error) {
// Ignore errors - port might not have any process
console.log(`[DevServerService] No process to kill on port ${port}`);
logger.debug(`No process to kill on port ${port}`);
}
}
@@ -251,11 +254,9 @@ class DevServerService {
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(`[DevServerService] Starting dev server on port ${port}`);
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
console.log(
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
);
logger.info(`Starting dev server on port ${port}`);
logger.debug(`Working directory (cwd): ${worktreePath}`);
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
// Spawn the dev process with PORT environment variable
const env = {
@@ -276,26 +277,26 @@ class DevServerService {
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on('data', (data: Buffer) => {
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
logger.debug(`[Port${port}] ${data.toString().trim()}`);
});
}
if (devProcess.stderr) {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
console.error(`[DevServer:${port}] ${msg}`);
logger.debug(`[Port${port}] ${msg}`);
});
}
devProcess.on('error', (error) => {
console.error(`[DevServerService] Process error:`, error);
logger.error(`Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on('exit', (code) => {
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
logger.info(`Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
@@ -352,9 +353,7 @@ class DevServerService {
// If we don't have a record of this server, it may have crashed/exited on its own
// Return success so the frontend can clear its state
if (!server) {
console.log(
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
);
logger.debug(`No server record for ${worktreePath}, may have already stopped`);
return {
success: true,
result: {
@@ -364,7 +363,7 @@ class DevServerService {
};
}
console.log(`[DevServerService] Stopping dev server for ${worktreePath}`);
logger.info(`Stopping dev server for ${worktreePath}`);
// Kill the process
if (server.process && !server.process.killed) {
@@ -434,7 +433,7 @@ class DevServerService {
* Stop all running dev servers (for cleanup)
*/
async stopAll(): Promise<void> {
console.log(`[DevServerService] Stopping all ${this.runningServers.size} dev servers`);
logger.info(`Stopping all ${this.runningServers.size} dev servers`);
for (const [worktreePath] of this.runningServers) {
await this.stopDevServer(worktreePath);

View File

@@ -4,7 +4,7 @@
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
@@ -56,10 +56,10 @@ export class FeatureLoader {
try {
// Paths are now absolute
await secureFs.unlink(oldPath);
console.log(`[FeatureLoader] Deleted orphaned image: ${oldPath}`);
logger.info(`Deleted orphaned image: ${oldPath}`);
} catch (error) {
// Ignore errors when deleting (file may already be gone)
logger.warn(`[FeatureLoader] Failed to delete image: ${oldPath}`, error);
logger.warn(`Failed to delete image: ${oldPath}`, error);
}
}
}
@@ -101,7 +101,7 @@ export class FeatureLoader {
try {
await secureFs.access(fullOriginalPath);
} catch {
logger.warn(`[FeatureLoader] Image not found, skipping: ${fullOriginalPath}`);
logger.warn(`Image not found, skipping: ${fullOriginalPath}`);
continue;
}
@@ -111,7 +111,7 @@ export class FeatureLoader {
// Copy the file
await secureFs.copyFile(fullOriginalPath, newPath);
console.log(`[FeatureLoader] Copied image: ${originalPath} -> ${newPath}`);
logger.info(`Copied image: ${originalPath} -> ${newPath}`);
// Try to delete the original temp file
try {
@@ -158,6 +158,13 @@ export class FeatureLoader {
return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md');
}
/**
* Get the path to a feature's raw-output.jsonl file
*/
getRawOutputPath(projectPath: string, featureId: string): string {
return path.join(this.getFeatureDir(projectPath, featureId), 'raw-output.jsonl');
}
/**
* Generate a new feature ID
*/
@@ -195,9 +202,7 @@ export class FeatureLoader {
const feature = JSON.parse(content);
if (!feature.id) {
logger.warn(
`[FeatureLoader] Feature ${featureId} missing required 'id' field, skipping`
);
logger.warn(`Feature ${featureId} missing required 'id' field, skipping`);
return null;
}
@@ -206,14 +211,9 @@ export class FeatureLoader {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
} else if (error instanceof SyntaxError) {
logger.warn(
`[FeatureLoader] Failed to parse feature.json for ${featureId}: ${error.message}`
);
logger.warn(`Failed to parse feature.json for ${featureId}: ${error.message}`);
} else {
logger.error(
`[FeatureLoader] Failed to load feature ${featureId}:`,
(error as Error).message
);
logger.error(`Failed to load feature ${featureId}:`, (error as Error).message);
}
return null;
}
@@ -248,7 +248,7 @@ export class FeatureLoader {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`[FeatureLoader] Failed to get feature ${featureId}:`, error);
logger.error(`Failed to get feature ${featureId}:`, error);
throw error;
}
}
@@ -274,6 +274,16 @@ export class FeatureLoader {
featureData.imagePaths
);
// Initialize description history with the initial description
const initialHistory: DescriptionHistoryEntry[] = [];
if (featureData.description && featureData.description.trim()) {
initialHistory.push({
description: featureData.description,
timestamp: new Date().toISOString(),
source: 'initial',
});
}
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || 'Uncategorized',
@@ -281,6 +291,7 @@ export class FeatureLoader {
...featureData,
id: featureId,
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};
// Write feature.json
@@ -292,11 +303,20 @@ export class FeatureLoader {
/**
* Update a feature (partial updates supported)
* @param projectPath - Path to the project
* @param featureId - ID of the feature to update
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
* @param preEnhancementDescription - Description before enhancement (for restoring original)
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer',
preEnhancementDescription?: string
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -313,11 +333,50 @@ export class FeatureLoader {
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
}
// Track description history if description changed
let updatedHistory = feature.descriptionHistory || [];
if (
updates.description !== undefined &&
updates.description !== feature.description &&
updates.description.trim()
) {
const timestamp = new Date().toISOString();
// If this is an enhancement and we have the pre-enhancement description,
// add the original text to history first (so user can restore to it)
if (
descriptionHistorySource === 'enhance' &&
preEnhancementDescription &&
preEnhancementDescription.trim()
) {
// Check if this pre-enhancement text is different from the last history entry
const lastEntry = updatedHistory[updatedHistory.length - 1];
if (!lastEntry || lastEntry.description !== preEnhancementDescription) {
const preEnhanceEntry: DescriptionHistoryEntry = {
description: preEnhancementDescription,
timestamp,
source: updatedHistory.length === 0 ? 'initial' : 'edit',
};
updatedHistory = [...updatedHistory, preEnhanceEntry];
}
}
// Add the new/enhanced description to history
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp,
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};
updatedHistory = [...updatedHistory, historyEntry];
}
// Merge updates
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
descriptionHistory: updatedHistory,
};
// Write back to file
@@ -335,10 +394,10 @@ export class FeatureLoader {
try {
const featureDir = this.getFeatureDir(projectPath, featureId);
await secureFs.rm(featureDir, { recursive: true, force: true });
console.log(`[FeatureLoader] Deleted feature ${featureId}`);
logger.info(`Deleted feature ${featureId}`);
return true;
} catch (error) {
logger.error(`[FeatureLoader] Failed to delete feature ${featureId}:`, error);
logger.error(`Failed to delete feature ${featureId}:`, error);
return false;
}
}
@@ -355,7 +414,24 @@ export class FeatureLoader {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`[FeatureLoader] Failed to get agent output for ${featureId}:`, error);
logger.error(`Failed to get agent output for ${featureId}:`, error);
throw error;
}
}
/**
* Get raw output for a feature (JSONL format for debugging)
*/
async getRawOutput(projectPath: string, featureId: string): Promise<string | null> {
try {
const rawOutputPath = this.getRawOutputPath(projectPath, featureId);
const content = (await secureFs.readFile(rawOutputPath, 'utf-8')) as string;
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to get raw output for ${featureId}:`, error);
throw error;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
/**
* Init Script Service - Executes worktree initialization scripts
*
* Runs the .automaker/worktree-init.sh script after worktree creation.
* Uses Git Bash on Windows for cross-platform shell script compatibility.
*/
import { spawn } from 'child_process';
import path from 'path';
import { createLogger } from '@automaker/utils';
import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
import { findCommand } from '../lib/cli-detection.js';
import type { EventEmitter } from '../lib/events.js';
import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
import * as secureFs from '../lib/secure-fs.js';
const logger = createLogger('InitScript');
export interface InitScriptOptions {
/** Absolute path to the project root */
projectPath: string;
/** Absolute path to the worktree directory */
worktreePath: string;
/** Branch name for this worktree */
branch: string;
/** Event emitter for streaming output */
emitter: EventEmitter;
}
interface ShellCommand {
shell: string;
args: string[];
}
/**
* Init Script Service
*
* Handles execution of worktree initialization scripts with cross-platform
* shell detection and proper streaming of output via WebSocket events.
*/
export class InitScriptService {
private cachedShellCommand: ShellCommand | null | undefined = undefined;
/**
* Get the path to the init script for a project
*/
getInitScriptPath(projectPath: string): string {
return path.join(projectPath, '.automaker', 'worktree-init.sh');
}
/**
* Check if the init script has already been run for a worktree
*/
async hasInitScriptRun(projectPath: string, branch: string): Promise<boolean> {
const metadata = await readWorktreeMetadata(projectPath, branch);
return metadata?.initScriptRan === true;
}
/**
* Find the appropriate shell for running scripts
* Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
*/
async findShellCommand(): Promise<ShellCommand | null> {
// Return cached result if available
if (this.cachedShellCommand !== undefined) {
return this.cachedShellCommand;
}
if (process.platform === 'win32') {
// On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
// WSL bash may not be properly configured and causes ENOENT errors
// First try known Git Bash installation paths
const gitBashPath = await findGitBashPath();
if (gitBashPath) {
logger.debug(`Found Git Bash at: ${gitBashPath}`);
this.cachedShellCommand = { shell: gitBashPath, args: [] };
return this.cachedShellCommand;
}
// Fall back to finding bash in PATH, but skip WSL bash
const bashInPath = await findCommand(['bash']);
if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
logger.debug(`Found bash in PATH at: ${bashInPath}`);
this.cachedShellCommand = { shell: bashInPath, args: [] };
return this.cachedShellCommand;
}
logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
this.cachedShellCommand = null;
return null;
}
// Unix-like systems: use getShellPaths() and check existence
const shellPaths = getShellPaths();
const posixShells = shellPaths.filter(
(p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
);
for (const shellPath of posixShells) {
try {
if (systemPathExists(shellPath)) {
this.cachedShellCommand = { shell: shellPath, args: [] };
return this.cachedShellCommand;
}
} catch {
// Path not allowed or doesn't exist, continue
}
}
// Ultimate fallback
if (systemPathExists('/bin/sh')) {
this.cachedShellCommand = { shell: '/bin/sh', args: [] };
return this.cachedShellCommand;
}
this.cachedShellCommand = null;
return null;
}
/**
* Run the worktree initialization script
* Non-blocking - returns immediately after spawning
*/
async runInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, worktreePath, branch, emitter } = options;
const scriptPath = this.getInitScriptPath(projectPath);
// Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
try {
await secureFs.access(scriptPath);
} catch {
logger.debug(`No init script found at ${scriptPath}`);
return;
}
// Check if already run
if (await this.hasInitScriptRun(projectPath, branch)) {
logger.info(`Init script already ran for branch "${branch}", skipping`);
return;
}
// Get shell command
const shellCmd = await this.findShellCommand();
if (!shellCmd) {
const error =
process.platform === 'win32'
? 'Git Bash not found. Please install Git for Windows to run init scripts.'
: 'No shell found (/bin/bash or /bin/sh)';
logger.error(error);
// Update metadata with error, preserving existing metadata
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error,
});
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error,
});
return;
}
logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
logger.debug(`Using shell: ${shellCmd.shell}`);
// Update metadata to mark as running
const existingMetadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: existingMetadata?.createdAt || new Date().toISOString(),
pr: existingMetadata?.pr,
initScriptRan: false,
initScriptStatus: 'running',
});
// Emit started event
emitter.emit('worktree:init-started', {
projectPath,
worktreePath,
branch,
});
// Build safe environment - only pass necessary variables, not all of process.env
// This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
const safeEnv: Record<string, string> = {
// Automaker-specific variables
AUTOMAKER_PROJECT_PATH: projectPath,
AUTOMAKER_WORKTREE_PATH: worktreePath,
AUTOMAKER_BRANCH: branch,
// Essential system variables
PATH: process.env.PATH || '',
HOME: process.env.HOME || '',
USER: process.env.USER || '',
TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
// Shell and locale
SHELL: process.env.SHELL || '',
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || '',
// Force color output even though we're not a TTY
FORCE_COLOR: '1',
npm_config_color: 'always',
CLICOLOR_FORCE: '1',
// Git configuration
GIT_TERMINAL_PROMPT: '0',
};
// Platform-specific additions
if (process.platform === 'win32') {
safeEnv.USERPROFILE = process.env.USERPROFILE || '';
safeEnv.APPDATA = process.env.APPDATA || '';
safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
safeEnv.TEMP = process.env.TEMP || '';
}
// Spawn the script with safe environment
const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
cwd: worktreePath,
env: safeEnv,
stdio: ['ignore', 'pipe', 'pipe'],
});
// Stream stdout
child.stdout?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stdout',
content,
});
});
// Stream stderr
child.stderr?.on('data', (data: Buffer) => {
const content = data.toString();
emitter.emit('worktree:init-output', {
projectPath,
branch,
type: 'stderr',
content,
});
});
// Handle completion
child.on('exit', async (code) => {
const success = code === 0;
const status = success ? 'success' : 'failed';
logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: status,
initScriptError: success ? undefined : `Exit code: ${code}`,
});
// Emit completion event
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success,
exitCode: code,
});
});
child.on('error', async (error) => {
logger.error(`Init script error for branch "${branch}":`, error);
// Update metadata
const metadata = await readWorktreeMetadata(projectPath, branch);
await writeWorktreeMetadata(projectPath, branch, {
branch,
createdAt: metadata?.createdAt || new Date().toISOString(),
pr: metadata?.pr,
initScriptRan: true,
initScriptStatus: 'failed',
initScriptError: error.message,
});
// Emit completion with error
emitter.emit('worktree:init-completed', {
projectPath,
worktreePath,
branch,
success: false,
error: error.message,
});
});
}
/**
* Force re-run the worktree initialization script
* Ignores the initScriptRan flag - useful for testing or re-setup
*/
async forceRunInitScript(options: InitScriptOptions): Promise<void> {
const { projectPath, branch } = options;
// Reset the initScriptRan flag so the script will run
const metadata = await readWorktreeMetadata(projectPath, branch);
if (metadata) {
await writeWorktreeMetadata(projectPath, branch, {
...metadata,
initScriptRan: false,
initScriptStatus: undefined,
initScriptError: undefined,
});
}
// Now run the script
await this.runInitScript(options);
}
}
// Singleton instance for convenience
let initScriptService: InitScriptService | null = null;
/**
* Get the singleton InitScriptService instance
*/
export function getInitScriptService(): InitScriptService {
if (!initScriptService) {
initScriptService = new InitScriptService();
}
return initScriptService;
}
// Export convenience functions that use the singleton
export const getInitScriptPath = (projectPath: string) =>
getInitScriptService().getInitScriptPath(projectPath);
export const hasInitScriptRun = (projectPath: string, branch: string) =>
getInitScriptService().hasInitScriptRun(projectPath, branch);
export const runInitScript = (options: InitScriptOptions) =>
getInitScriptService().runInitScript(options);
export const forceRunInitScript = (options: InitScriptOptions) =>
getInitScriptService().forceRunInitScript(options);

View File

@@ -22,16 +22,18 @@ import type {
Credentials,
ProjectSettings,
KeyboardShortcuts,
AIProfile,
ProjectRef,
TrashedProjectRef,
BoardBackgroundSettings,
WorktreeInfo,
PhaseModelConfig,
PhaseModelEntry,
} from '../types/settings.js';
import {
DEFAULT_GLOBAL_SETTINGS,
DEFAULT_CREDENTIALS,
DEFAULT_PROJECT_SETTINGS,
DEFAULT_PHASE_MODELS,
SETTINGS_VERSION,
CREDENTIALS_VERSION,
PROJECT_SETTINGS_VERSION,
@@ -132,6 +134,9 @@ export class SettingsService {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS);
// Migrate legacy enhancementModel/validationModel to phaseModels
const migratedPhaseModels = this.migratePhaseModels(settings);
// Apply any missing defaults (for backwards compatibility)
let result: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
@@ -140,21 +145,37 @@ export class SettingsService {
...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
...settings.keyboardShortcuts,
},
phaseModels: migratedPhaseModels,
};
// Version-based migrations
const storedVersion = settings.version || 1;
let needsSave = false;
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
// Sandbox mode can cause issues on some systems, so we're disabling it by default
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
result.version = SETTINGS_VERSION;
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
if (storedVersion < 3) {
logger.info(
`Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format`
);
needsSave = true;
}
// Migration v3 -> v4: Add onboarding/setup wizard state fields
// Older settings files never stored setup state in settings.json (it lived in localStorage),
// so default to "setup complete" for existing installs to avoid forcing re-onboarding.
if (storedVersion < 4) {
if (settings.setupComplete === undefined) result.setupComplete = true;
if (settings.isFirstRun === undefined) result.isFirstRun = false;
if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false;
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
}
// Save migrated settings if needed
if (needsSave) {
try {
@@ -169,6 +190,67 @@ export class SettingsService {
return result;
}
/**
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
*
* Handles backwards compatibility for settings created before phaseModels existed.
* Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3).
* Legacy fields take precedence over defaults but phaseModels takes precedence over legacy.
*
* @param settings - Raw settings from file
* @returns Complete PhaseModelConfig with all fields populated
*/
private migratePhaseModels(settings: Partial<GlobalSettings>): PhaseModelConfig {
// Start with defaults
const result: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
// If phaseModels exists, use it (with defaults for any missing fields)
if (settings.phaseModels) {
// Merge with defaults and convert any string values to PhaseModelEntry
const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS };
for (const key of Object.keys(settings.phaseModels) as Array<keyof PhaseModelConfig>) {
const value = settings.phaseModels[key];
if (value !== undefined) {
// Convert string to PhaseModelEntry if needed (v2 -> v3 migration)
merged[key] = this.toPhaseModelEntry(value);
}
}
return merged;
}
// Migrate legacy fields if phaseModels doesn't exist
// These were the only two legacy fields that existed
if (settings.enhancementModel) {
result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel);
logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`);
}
if (settings.validationModel) {
result.validationModel = this.toPhaseModelEntry(settings.validationModel);
logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`);
}
return result;
}
/**
* Convert a phase model value to PhaseModelEntry format
*
* Handles migration from string format (v2) to object format (v3).
* - String values like 'sonnet' become { model: 'sonnet' }
* - Object values are returned as-is (with type assertion)
*
* @param value - Phase model value (string or PhaseModelEntry)
* @returns PhaseModelEntry object
*/
private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry {
if (typeof value === 'string') {
// v2 format: just a model string
return { model: value as PhaseModelEntry['model'] };
}
// v3 format: already a PhaseModelEntry object
return value;
}
/**
* Update global settings with partial changes
*
@@ -183,17 +265,78 @@ export class SettingsService {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
// Guard against destructive "empty array/object" overwrites.
// During auth transitions, the UI can briefly have default/empty state and accidentally
// sync it, wiping persisted settings (especially `projects`).
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
let attemptedProjectWipe = false;
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if (
Array.isArray(nextVal) &&
nextVal.length === 0 &&
Array.isArray(curVal) &&
curVal.length > 0
) {
delete sanitizedUpdates[key];
}
};
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
}
ignoreEmptyArrayOverwrite('trashedProjects');
ignoreEmptyArrayOverwrite('projectHistory');
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
// Empty object overwrite guard
if (
sanitizedUpdates.lastSelectedSessionByProject &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
current.lastSelectedSessionByProject &&
Object.keys(current.lastSelectedSessionByProject).length > 0
) {
delete sanitizedUpdates.lastSelectedSessionByProject;
}
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {
delete sanitizedUpdates.theme;
}
const updated: GlobalSettings = {
...current,
...updates,
...sanitizedUpdates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (updates.keyboardShortcuts) {
if (sanitizedUpdates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...updates.keyboardShortcuts,
...sanitizedUpdates.keyboardShortcuts,
};
}
// Deep merge phaseModels if provided
if (sanitizedUpdates.phaseModels) {
updated.phaseModels = {
...current.phaseModels,
...sanitizedUpdates.phaseModels,
};
}
@@ -434,13 +577,29 @@ export class SettingsService {
}
}
// Parse setup wizard state (previously stored in localStorage)
let setupState: Record<string, unknown> = {};
if (localStorageData['automaker-setup']) {
try {
const parsed = JSON.parse(localStorageData['automaker-setup']);
setupState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-setup: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
setupComplete:
setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false,
isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true,
skipClaudeSetup:
setupState.skipClaudeSetup !== undefined
? (setupState.skipClaudeSetup as boolean)
: false,
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
kanbanCardDetailLevel:
(appState.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel']) || 'standard',
maxConcurrency: (appState.maxConcurrency as number) || 3,
defaultSkipTests:
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
@@ -448,19 +607,21 @@ export class SettingsService {
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
skipVerificationInAutoMode:
appState.skipVerificationInAutoMode !== undefined
? (appState.skipVerificationInAutoMode as boolean)
: false,
useWorktrees:
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
defaultPlanningMode:
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false,
defaultAIProfileId: (appState.defaultAIProfileId as string | null) || null,
muteDoneSound: (appState.muteDoneSound as boolean) || false,
enhancementModel:
(appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet',
keyboardShortcuts:
(appState.keyboardShortcuts as KeyboardShortcuts) ||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
aiProfiles: (appState.aiProfiles as AIProfile[]) || [],
projects: (appState.projects as ProjectRef[]) || [],
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
projectHistory: (appState.projectHistory as string[]) || [],

View File

@@ -12,6 +12,9 @@ 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';
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 {
@@ -219,7 +222,7 @@ export class TerminalService extends EventEmitter {
// Reject paths with null bytes (could bypass path checks)
if (cwd.includes('\0')) {
console.warn(`[Terminal] Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`);
return homeDir;
}
@@ -242,12 +245,10 @@ export class TerminalService extends EventEmitter {
if (statResult.isDirectory()) {
return cwd;
}
console.warn(`[Terminal] Path exists but is not a directory: ${cwd}, falling back to home`);
logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`);
return homeDir;
} catch {
console.warn(
`[Terminal] Working directory does not exist or not allowed: ${cwd}, falling back to home`
);
logger.warn(`Working directory does not exist or not allowed: ${cwd}, falling back to home`);
return homeDir;
}
}
@@ -272,7 +273,7 @@ export class TerminalService extends EventEmitter {
setMaxSessions(limit: number): void {
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
maxSessions = limit;
console.log(`[Terminal] Max sessions limit updated to ${limit}`);
logger.info(`Max sessions limit updated to ${limit}`);
}
}
@@ -283,7 +284,7 @@ export class TerminalService extends EventEmitter {
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Check session limit
if (this.sessions.size >= maxSessions) {
console.error(`[Terminal] Max sessions (${maxSessions}) reached, refusing new session`);
logger.error(`Max sessions (${maxSessions}) reached, refusing new session`);
return null;
}
@@ -319,7 +320,7 @@ export class TerminalService extends EventEmitter {
...options.env,
};
console.log(`[Terminal] Creating session ${id} with shell: ${shell} in ${cwd}`);
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
const ptyProcess = pty.spawn(shell, shellArgs, {
name: 'xterm-256color',
@@ -391,13 +392,13 @@ export class TerminalService extends EventEmitter {
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
console.log(`[Terminal] Session ${id} exited with code ${exitCode}`);
logger.info(`Session ${id} exited with code ${exitCode}`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode));
this.emit('exit', id, exitCode);
});
console.log(`[Terminal] Session ${id} created successfully`);
logger.info(`Session ${id} created successfully`);
return session;
}
@@ -407,7 +408,7 @@ export class TerminalService extends EventEmitter {
write(sessionId: string, data: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found`);
logger.warn(`Session ${sessionId} not found`);
return false;
}
session.pty.write(data);
@@ -422,7 +423,7 @@ export class TerminalService extends EventEmitter {
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`[Terminal] Session ${sessionId} not found for resize`);
logger.warn(`Session ${sessionId} not found for resize`);
return false;
}
try {
@@ -448,7 +449,7 @@ export class TerminalService extends EventEmitter {
return true;
} catch (error) {
console.error(`[Terminal] Error resizing session ${sessionId}:`, error);
logger.error(`Error resizing session ${sessionId}:`, error);
session.resizeInProgress = false; // Clear flag on error
return false;
}
@@ -476,14 +477,14 @@ export class TerminalService extends EventEmitter {
}
// First try graceful SIGTERM to allow process cleanup
console.log(`[Terminal] Session ${sessionId} sending SIGTERM`);
logger.info(`Session ${sessionId} sending SIGTERM`);
session.pty.kill('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)) {
console.log(`[Terminal] Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try {
session.pty.kill('SIGKILL');
} catch {
@@ -494,10 +495,10 @@ export class TerminalService extends EventEmitter {
}
}, 1000);
console.log(`[Terminal] Session ${sessionId} kill initiated`);
logger.info(`Session ${sessionId} kill initiated`);
return true;
} catch (error) {
console.error(`[Terminal] Error killing session ${sessionId}:`, error);
logger.error(`Error killing session ${sessionId}:`, error);
// Still try to remove from map even if kill fails
this.sessions.delete(sessionId);
return false;
@@ -580,7 +581,7 @@ export class TerminalService extends EventEmitter {
* Clean up all sessions
*/
cleanup(): void {
console.log(`[Terminal] Cleaning up ${this.sessions.size} sessions`);
logger.info(`Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
// Clean up flush timeout