Merge branch 'v0.9.0rc' into feat/subagents-skills

This commit is contained in:
webdevcody
2026-01-08 00:33:30 -05:00
218 changed files with 18537 additions and 4390 deletions

View File

@@ -13,6 +13,8 @@ import {
isAbortError,
loadContextFiles,
createLogger,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
@@ -20,7 +22,6 @@ import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -175,6 +176,18 @@ export class AgentService {
session.thinkingLevel = thinkingLevel;
}
// 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'] = [];
if (imagePaths && imagePaths.length > 0) {
@@ -235,12 +248,6 @@ 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]');
@@ -286,7 +293,6 @@ 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,
});
@@ -350,7 +356,6 @@ export class AgentService {
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: settingSources.length > 0 ? settingSources : undefined,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
agents: customSubagents, // Pass custom subagents for task delegation
@@ -434,6 +439,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,
};
}
}

View File

@@ -47,7 +47,6 @@ import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -1314,7 +1313,6 @@ Format your response as a structured markdown document.`;
allowedTools: sdkOptions.allowedTools as string[],
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
thinkingLevel: analysisThinkingLevel, // Pass thinking level
};
@@ -1784,9 +1782,13 @@ Format your response as a structured markdown document.`;
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings();
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures)
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
);
return readyFeatures;
@@ -1989,6 +1991,18 @@ This helps parse your summary correctly in the output logs.`;
const planningMode = options?.planningMode || 'skip';
const previousContent = options?.previousContent;
// Validate vision support before processing images
const effectiveModel = model || 'claude-sonnet-4-20250514';
if (imagePaths && imagePaths.length > 0) {
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 (like Claude models), or remove the images and try again.`
);
}
}
// Check if this planning mode can generate a spec/plan that needs approval
// - spec and full always generate specs
// - lite only generates approval-ready content when requirePlanApproval is true
@@ -2062,9 +2076,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
@@ -2076,7 +2087,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
model: model,
abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
thinkingLevel: options?.thinkingLevel,
});
@@ -2119,7 +2129,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
abortController,
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
};
@@ -2202,9 +2211,23 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}, WRITE_DEBOUNCE_MS);
};
// Heartbeat logging so "silent" model calls are visible.
// Some runs can take a while before the first streamed message arrives.
const streamStartTime = Date.now();
let receivedAnyStreamMessage = false;
const STREAM_HEARTBEAT_MS = 15_000;
const streamHeartbeat = setInterval(() => {
if (receivedAnyStreamMessage) return;
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
logger.info(
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
);
}, STREAM_HEARTBEAT_MS);
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
try {
streamLoop: for await (const msg of stream) {
receivedAnyStreamMessage = true;
// Log raw stream event for debugging
appendRawEvent(msg);
@@ -2721,6 +2744,7 @@ Implement all the changes described in the plan above.`;
}
}
} finally {
clearInterval(streamHeartbeat);
// ALWAYS clear pending timeouts to prevent memory leaks
// This runs on success, error, or abort
if (writeTimeout) {

View File

@@ -0,0 +1,88 @@
import * as os from 'os';
import { findCodexCliPath } from '@automaker/platform';
import { checkCodexAuthentication } from '../lib/codex-auth.js';
export interface CodexRateLimitWindow {
limit: number;
used: number;
remaining: number;
usedPercent: number;
windowDurationMins: number;
resetsAt: number;
}
export interface CodexCreditsSnapshot {
balance?: string;
unlimited?: boolean;
hasCredits?: boolean;
}
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
export interface CodexUsageData {
rateLimits: {
primary?: CodexRateLimitWindow;
secondary?: CodexRateLimitWindow;
credits?: CodexCreditsSnapshot;
planType?: CodexPlanType;
} | null;
lastUpdated: string;
}
/**
* Codex Usage Service
*
* Unlike Claude Code CLI which provides a `/usage` command, Codex CLI
* does not expose usage statistics directly. This service returns a
* clear message explaining this limitation.
*
* Future enhancement: Could query OpenAI API headers for rate limit info.
*/
export class CodexUsageService {
private codexBinary = 'codex';
private isWindows = os.platform() === 'win32';
private cachedCliPath: string | null = null;
/**
* Check if Codex CLI is available on the system
*/
async isAvailable(): Promise<boolean> {
// Prefer our platform-aware resolver over `which/where` because the server
// process PATH may not include npm global bins (nvm/fnm/volta/pnpm).
this.cachedCliPath = await findCodexCliPath();
return Boolean(this.cachedCliPath);
}
/**
* Attempt to fetch usage data
*
* Note: Codex CLI doesn't provide usage statistics like Claude Code does.
* This method returns an error explaining this limitation.
*/
async fetchUsageData(): Promise<CodexUsageData> {
// Check authentication status first
const isAuthenticated = await this.checkAuthentication();
if (!isAuthenticated) {
throw new Error("Codex is not authenticated. Please run 'codex login' to authenticate.");
}
// Codex CLI doesn't provide a usage command
// Return an error that will be caught and displayed
throw new Error(
'Codex usage statistics are not available. Unlike Claude Code, the Codex CLI does not provide a built-in usage command. ' +
'Usage limits are enforced by OpenAI but cannot be queried via the CLI. ' +
'Check your OpenAI dashboard at https://platform.openai.com/usage for detailed usage information.'
);
}
/**
* Check if Codex is authenticated
*/
private async checkAuthentication(): Promise<boolean> {
// Use the cached CLI path if available, otherwise fall back to finding it
const cliPath = this.cachedCliPath || (await findCodexCliPath());
const authCheck = await checkCodexAuthentication(cliPath);
return authCheck.authenticated;
}
}

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 {
@@ -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,18 @@ 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'
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -313,11 +331,28 @@ 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 historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp: new Date().toISOString(),
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

View File

@@ -153,14 +153,6 @@ export class SettingsService {
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;
needsSave = true;
}
// 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) {
@@ -170,6 +162,16 @@ export class SettingsService {
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;
@@ -264,25 +266,79 @@ 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('aiProfiles');
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 (updates.phaseModels) {
if (sanitizedUpdates.phaseModels) {
updated.phaseModels = {
...current.phaseModels,
...updates.phaseModels,
...sanitizedUpdates.phaseModels,
};
}
@@ -523,8 +579,26 @@ 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,
@@ -537,6 +611,10 @@ export class SettingsService {
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
skipVerificationInAutoMode:
appState.skipVerificationInAutoMode !== undefined
? (appState.skipVerificationInAutoMode as boolean)
: false,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode: